mirror of
https://github.com/kerberos-io/agent.git
synced 2026-11-05 20:20:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8016b3ba93 | ||
|
|
4dc6fe7e18 | ||
|
|
553fd8e065 | ||
|
|
e1b886edd8 | ||
|
|
24bcc296f1 | ||
|
|
42e67599ab | ||
|
|
f64b762eee | ||
|
|
9c754b9b91 | ||
|
|
1445789694 |
@@ -384,6 +384,7 @@ See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI,
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL` | 1-based channel on multi-channel ONVIF devices (encoders, NVRs) used for PTZ, presets and focus. | "0" (first profile) |
|
||||
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
|
||||
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
|
||||
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
|
||||
@@ -464,6 +465,17 @@ Smaller chunks provide more frequent resumable checkpoints but create more HTTP
|
||||
requests. Larger chunks reduce request overhead but require more data to be
|
||||
retransmitted when a request fails.
|
||||
|
||||
An HTTP 500 during resumable creation is a server/provider failure, not proof
|
||||
that the recording filename is invalid. Creation errors include up to 4 KiB of
|
||||
the server response, and the final retry error retains the latest failure cause.
|
||||
Created upload sessions remain usable if only the optional creation response
|
||||
body is interrupted; the successful response's `Location` is retained.
|
||||
Check the corresponding Hub/Vault logs and storage-provider configuration.
|
||||
As a temporary diagnostic workaround, set `AGENT_DISABLE_RESUMABLE_UPLOAD=true`
|
||||
and restart Agent to use ordinary uploads. This does not change the recording
|
||||
filename or remove its structured metadata, but interrupted uploads restart
|
||||
from the beginning. Remove the override after fixing the provider issue.
|
||||
|
||||
### Custom metadata headers for Kerberos Vault
|
||||
|
||||
Use `AGENT_KERBEROSVAULT_CUSTOM_HEADERS` to attach your own metadata (for
|
||||
|
||||
@@ -319,6 +319,7 @@ func heartbeatONVIFCameraKey(camera models.IPCamera) string {
|
||||
camera.ONVIFXAddr,
|
||||
camera.ONVIFUsername,
|
||||
camera.ONVIFPassword,
|
||||
strconv.Itoa(camera.ONVIFChannel),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
@@ -413,7 +414,7 @@ func (state *heartbeatONVIFState) refreshStaticPayload(device *goonvif.Device) h
|
||||
payload.panTilt = "true"
|
||||
}
|
||||
|
||||
presets, err := heartbeatGetPresetsFromDevice(device)
|
||||
presets, err := heartbeatGetPresetsFromDevice(device, state.cameraConfiguration.ONVIFChannel)
|
||||
if err == nil && len(presets) > 0 {
|
||||
payload.presets = "true"
|
||||
presetsList, marshalErr := json.Marshal(presets)
|
||||
@@ -1379,6 +1380,10 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
|
||||
|
||||
// Check if we need to enable the realtime processing
|
||||
if config.RealtimeProcessing == "true" {
|
||||
if mqttClient == nil {
|
||||
log.Warn("cloud.RealtimeProcessing(): stopping as MQTT client is not configured.")
|
||||
return
|
||||
}
|
||||
|
||||
hubKey := ""
|
||||
if config.Cloud == "s3" && config.S3 != nil && config.S3.Publickey != "" {
|
||||
|
||||
@@ -296,7 +296,7 @@ func TestHeartbeatONVIFPayloadCachesStaticAndReusesLoopSubscription(t *testing.T
|
||||
ptzFunctionCalls++
|
||||
return nil, true, true
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
return []models.OnvifActionPreset{{Name: "Lobby", Token: "1"}}, nil
|
||||
}
|
||||
@@ -397,7 +397,7 @@ func TestHeartbeatONVIFPayloadRefreshesAfterCameraConfigChange(t *testing.T) {
|
||||
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
|
||||
return nil, false, true
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
return nil, nil
|
||||
}
|
||||
@@ -477,7 +477,7 @@ func TestHeartbeatONVIFPayloadRetriesStaticFetchFailuresWithoutReconnect(t *test
|
||||
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
|
||||
return nil, true, true
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
if presetCalls == 1 {
|
||||
return nil, errors.New("temporary preset failure")
|
||||
@@ -618,7 +618,7 @@ func TestHeartbeatONVIFPayloadReconnectFailureInvalidatesCache(t *testing.T) {
|
||||
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
|
||||
return nil, true, false
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
return []models.OnvifActionPreset{{Name: "Preset", Token: "1"}}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
func TestHandleRealtimeProcessingWithoutMQTTClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config models.Config
|
||||
wantWarning bool
|
||||
}{
|
||||
{
|
||||
name: "enabled without Hub credentials",
|
||||
config: models.Config{RealtimeProcessing: "true"},
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
config: models.Config{RealtimeProcessing: "false"},
|
||||
},
|
||||
{
|
||||
name: "offline",
|
||||
config: models.Config{RealtimeProcessing: "true", Offline: "true"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hook := test.NewGlobal()
|
||||
defer hook.Reset()
|
||||
|
||||
configuration := &models.Configuration{Config: tt.config}
|
||||
// No cursor or decoder should be accessed without an MQTT client.
|
||||
HandleRealtimeProcessing(nil, configuration, nil, nil, nil)
|
||||
|
||||
warnings := 0
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Level == log.WarnLevel {
|
||||
warnings++
|
||||
if entry.Message != "cloud.RealtimeProcessing(): stopping as MQTT client is not configured." {
|
||||
t.Errorf("unexpected warning: %s", entry.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
wantWarnings := 0
|
||||
if tt.wantWarning {
|
||||
wantWarnings = 1
|
||||
}
|
||||
if warnings != wantWarnings {
|
||||
t.Fatalf("warnings = %d, want %d", warnings, wantWarnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
// and loop forever, wedging the upload worker on one recording and saturating the
|
||||
// uplink.
|
||||
highWaterOffset := int64(0)
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// (1) Ensure we have an active upload URL, creating one if needed.
|
||||
@@ -171,6 +172,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
|
||||
lastStatus = status
|
||||
if cerr != nil {
|
||||
lastErr = cerr
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
|
||||
// The vault does not implement tus; let the caller fall back.
|
||||
return false, false, false, "", cerr
|
||||
@@ -187,6 +189,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
offset, status, herr := tusHead(client, uploadURL, setHeaders)
|
||||
lastStatus = status
|
||||
if herr != nil {
|
||||
lastErr = herr
|
||||
if status == http.StatusNotFound || status == http.StatusGone {
|
||||
// The upload expired/was removed server-side; start over.
|
||||
removeTusResumeState(sidecar)
|
||||
@@ -243,6 +246,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
|
||||
lastStatus = status
|
||||
if perr != nil {
|
||||
lastErr = perr
|
||||
if status >= 400 {
|
||||
// Definitive rejection (e.g. provider push failed during finalize).
|
||||
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
|
||||
@@ -289,7 +293,12 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
// report responded=false so the caller keeps the recording queued and retries
|
||||
// later instead of consuming its retry budget and entering the long back-off
|
||||
// timeout.
|
||||
return false, lastStatus > 0, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
|
||||
message := label + ": resumable upload did not complete after retries"
|
||||
err = errors.New(message)
|
||||
if lastErr != nil {
|
||||
err = fmt.Errorf("%s: %w", message, lastErr)
|
||||
}
|
||||
return false, lastStatus > 0, true, "resumable upload did not complete after retries", err
|
||||
}
|
||||
|
||||
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
|
||||
@@ -395,11 +404,29 @@ func tusCreate(client *http.Client, baseURL string, size int64, metadata string,
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
const maxErrorBodyBytes = 4096
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes+1))
|
||||
if readErr != nil {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s; read error response: %w", resp.Status, readErr)
|
||||
}
|
||||
truncated := len(body) > maxErrorBodyBytes
|
||||
if truncated {
|
||||
body = body[:maxErrorBodyBytes]
|
||||
}
|
||||
message := strings.TrimSpace(string(body))
|
||||
if truncated {
|
||||
message += " [truncated]"
|
||||
}
|
||||
if message != "" {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s: %q", resp.Status, message)
|
||||
}
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
|
||||
}
|
||||
// Location identifies the created upload; its response body is optional.
|
||||
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
|
||||
log.WithError(err).WithField("component", "tus").Debug("Could not drain optional upload creation response body")
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return "", resp.StatusCode, errors.New("missing Location header in create response")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,146 @@ import (
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
)
|
||||
|
||||
func TestResumableEOFRestartPreservesLatestFailure(t *testing.T) {
|
||||
previousDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = 0
|
||||
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
fake := newFakeTus()
|
||||
fake.failFinalize = 1
|
||||
var heads atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead && heads.Add(1) <= 2 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
fake.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
|
||||
if uploaded || !responded || !supported || err == nil ||
|
||||
!strings.Contains(err.Error(), "unexpected status on PATCH: 502 Bad Gateway") ||
|
||||
strings.Contains(err.Error(), "503 Service Unavailable") {
|
||||
t.Fatalf("upload = %t/%t/%t, %v; want the latest finalization failure after EOF restart", uploaded, responded, supported, err)
|
||||
}
|
||||
patches, _ := fake.patchCounts()
|
||||
if heads.Load() != 4 || fake.createCount() != 1 || patches != 1 ||
|
||||
len(fake.requestsForMethod(http.MethodDelete)) != 1 {
|
||||
t.Fatalf("unexpected retry sequence: heads=%d, creates=%d, patches=%d", heads.Load(), fake.createCount(), patches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableCreationKeepsLocationAfterBodyReadError(t *testing.T) {
|
||||
previousDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = 0
|
||||
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
payload := []byte("recording")
|
||||
withRecording(t, fileName, payload)
|
||||
fake := newFakeTus()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
fake.ServeHTTP(w, r)
|
||||
_, _ = io.WriteString(w, "short")
|
||||
return
|
||||
}
|
||||
fake.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
|
||||
if !uploaded || !responded || !supported || err != nil {
|
||||
t.Fatalf("upload = %t/%t/%t, %v; want success despite the interrupted optional creation body", uploaded, responded, supported, err)
|
||||
}
|
||||
if fake.createCount() != 1 || fake.totalBytes() != int64(len(payload)) {
|
||||
t.Fatalf("created %d slots and uploaded %d bytes; want one reused slot and %d bytes", fake.createCount(), fake.totalBytes(), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusCreateStillRequiresLocationAfterBodyReadError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, "short")
|
||||
}))
|
||||
defer server.Close()
|
||||
location, status, err := tusCreate(server.Client(), server.URL, 8, "",
|
||||
func(http.Header, string) {}, "1767355200123-device-key.mp4")
|
||||
if location != "" || status != http.StatusCreated || err == nil ||
|
||||
!strings.Contains(err.Error(), "missing Location") {
|
||||
t.Fatalf("creation = %q, %d, %v; want missing Location error", location, status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusCreateReportsBoundedServerErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"server error", "ERR_INTERNAL_SERVER_ERROR: A region must be set when sending requests to S3.\n", "A region must be set"},
|
||||
{"empty error", "", "500 Internal Server Error"},
|
||||
{"large error", strings.Repeat("x", 10000), "[truncated]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
location, status, err := tusCreate(server.Client(), server.URL, 8, "",
|
||||
func(http.Header, string) {}, "1767355200123-device-key.mp4")
|
||||
if location != "" || status != http.StatusInternalServerError || err == nil ||
|
||||
!strings.Contains(err.Error(), tc.want) || len(err.Error()) > 4300 {
|
||||
t.Fatalf("creation = %q, %d, %v; want bounded server error", location, status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableCreateFailureRetainsServerCause(t *testing.T) {
|
||||
previousDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = 0
|
||||
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
|
||||
for _, integration := range []string{"vault", "hub"} {
|
||||
t.Run(integration, func(t *testing.T) {
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
if r.Method != http.MethodPost || r.URL.Path != tusUploadPath {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, "ERR_INTERNAL_SERVER_ERROR: A region must be set when sending requests to S3.")
|
||||
}))
|
||||
defer server.Close()
|
||||
var uploaded, responded, supported bool
|
||||
var err error
|
||||
if integration == "vault" {
|
||||
uploaded, responded, supported, _, err = uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
|
||||
} else {
|
||||
uploaded, responded, supported, _, err = uploadHubResumable(testHubConfig(server.URL), fileName, "test", "hub")
|
||||
}
|
||||
if uploaded || !responded || !supported || err == nil ||
|
||||
!strings.Contains(err.Error(), "resumable upload did not complete after retries") ||
|
||||
!strings.Contains(err.Error(), "A region must be set") {
|
||||
t.Fatalf("upload = %t/%t/%t, %v; want original server cause after retries", uploaded, responded, supported, err)
|
||||
}
|
||||
if requests.Load() != 4 {
|
||||
t.Fatalf("creation attempts = %d, want 4 without legacy fallback", requests.Load())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("data/recordings", fileName)); err != nil {
|
||||
t.Fatalf("failed upload must retain the local recording: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadKerberosVaultReadsDatedLocalRecordings(t *testing.T) {
|
||||
previousCount, previousTimeout := kstorageRetryCount, kstorageRetryTimeout
|
||||
t.Cleanup(func() { kstorageRetryCount, kstorageRetryTimeout = previousCount, previousTimeout })
|
||||
|
||||
@@ -394,6 +394,9 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
|
||||
case "AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD":
|
||||
configuration.Config.Capture.IPCamera.ONVIFPassword = value
|
||||
break
|
||||
case "AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL":
|
||||
configuration.Config.Capture.IPCamera.ONVIFChannel, _ = strconv.Atoi(value)
|
||||
break
|
||||
|
||||
/* Recording mode */
|
||||
case "AGENT_CAPTURE_RECORDING":
|
||||
|
||||
@@ -12,6 +12,7 @@ type OnvifCredentials struct {
|
||||
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
|
||||
ONVIFUsername string `json:"onvif_username,omitempty" bson:"onvif_username"`
|
||||
ONVIFPassword string `json:"onvif_password,omitempty" bson:"onvif_password"`
|
||||
ONVIFChannel int `json:"onvif_channel,omitempty" bson:"onvif_channel,omitempty"` // 1-based; 0 = first profile
|
||||
}
|
||||
|
||||
type CameraStreams struct {
|
||||
@@ -68,6 +69,11 @@ type OnvifZoom struct {
|
||||
Zoom float64 `json:"zoom,omitempty" bson:"zoom"`
|
||||
}
|
||||
|
||||
type OnvifFocus struct {
|
||||
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
|
||||
Focus float64 `json:"focus,omitempty" bson:"focus"`
|
||||
}
|
||||
|
||||
type OnvifPreset struct {
|
||||
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
|
||||
Preset string `json:"preset,omitempty" bson:"preset"`
|
||||
|
||||
@@ -105,6 +105,7 @@ type IPCamera struct {
|
||||
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
|
||||
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
|
||||
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
|
||||
ONVIFChannel int `json:"onvif_channel,omitempty" bson:"onvif_channel,omitempty"` // 1-based video input on multi-channel devices (encoders, NVRs); 0 = first profile
|
||||
SPSNALUs [][]byte `json:"sps_nalus,omitempty" bson:"sps_nalus,omitempty"`
|
||||
PPSNALUs [][]byte `json:"pps_nalus,omitempty" bson:"pps_nalus,omitempty"`
|
||||
VPSNALUs [][]byte `json:"vps_nalus,omitempty" bson:"vps_nalus,omitempty"`
|
||||
|
||||
@@ -12,6 +12,7 @@ type OnvifActionPTZ struct {
|
||||
Down int `json:"down" bson:"down"`
|
||||
Center int `json:"center" bson:"center"`
|
||||
Zoom float64 `json:"zoom" bson:"zoom"`
|
||||
Focus float64 `json:"focus" bson:"focus"`
|
||||
X float64 `json:"x" bson:"x"`
|
||||
Y float64 `json:"y" bson:"y"`
|
||||
Z float64 `json:"z" bson:"z"`
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
"github.com/kerberos-io/onvif/media"
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
// Multi-channel devices (encoders, NVRs) expose several media profiles per
|
||||
// video input, e.g. a Hanwha SPE-420 returns DefaultProfile-01-0..03-0 for
|
||||
// channel 1 and DefaultProfile-01-1..03-1 for channel 2. PTZ, presets and
|
||||
// focus must be addressed with a profile (or video source) of the channel the
|
||||
// camera is wired to, otherwise commands drive the wrong camera.
|
||||
//
|
||||
// A channel is 1-based, counted in the order the device first lists each video
|
||||
// source in GetProfiles. Channel 0 (unset) keeps the historic behaviour of
|
||||
// using the first profile, so single-sensor cameras need no configuration.
|
||||
|
||||
// GetTokenFromChannel returns the media profile token to use for PTZ commands
|
||||
// on the given 1-based channel.
|
||||
func GetTokenFromChannel(device *onvif.Device, channel int) (xsdonvif.ReferenceToken, error) {
|
||||
if channel <= 0 {
|
||||
return GetTokenFromProfile(device, 0)
|
||||
}
|
||||
profiles, err := getMediaProfiles(device)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return profile.Token, nil
|
||||
}
|
||||
|
||||
// GetVideoSourceTokenFromChannel returns the video source token of the given
|
||||
// 1-based channel, as used by the imaging (focus) service.
|
||||
func GetVideoSourceTokenFromChannel(device *onvif.Device, channel int) (xsdonvif.ReferenceToken, error) {
|
||||
if channel <= 0 {
|
||||
return GetVideoSourceTokenFromProfile(device, 0)
|
||||
}
|
||||
profiles, err := getMediaProfiles(device)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *profile.VideoSourceConfiguration.SourceToken, nil
|
||||
}
|
||||
|
||||
func getMediaProfiles(device *onvif.Device) ([]xsdonvif.Profile, error) {
|
||||
resp, err := device.CallMethod(media.GetProfiles{})
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decodedXML, et, err := getXMLNode(string(b), "GetProfilesResponse")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profilesResp media.GetProfilesResponse
|
||||
if err := decodedXML.DecodeElement(&profilesResp, et); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profilesResp.Profiles, nil
|
||||
}
|
||||
|
||||
// selectChannelProfile groups profiles by video source and returns a profile
|
||||
// of the channel-th source (1-based). Within a channel it prefers the first
|
||||
// profile that has a PTZ configuration attached.
|
||||
func selectChannelProfile(profiles []xsdonvif.Profile, channel int) (xsdonvif.Profile, error) {
|
||||
if channel <= 0 {
|
||||
return xsdonvif.Profile{}, errors.New("onvif channel must be 1 or higher")
|
||||
}
|
||||
|
||||
var sources []xsdonvif.ReferenceToken
|
||||
bySource := map[xsdonvif.ReferenceToken][]xsdonvif.Profile{}
|
||||
for _, profile := range profiles {
|
||||
if profile.VideoSourceConfiguration == nil || profile.VideoSourceConfiguration.SourceToken == nil {
|
||||
continue
|
||||
}
|
||||
source := *profile.VideoSourceConfiguration.SourceToken
|
||||
if _, seen := bySource[source]; !seen {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
bySource[source] = append(bySource[source], profile)
|
||||
}
|
||||
|
||||
if channel > len(sources) {
|
||||
return xsdonvif.Profile{}, fmt.Errorf("onvif channel %d not found: device exposes %d video source(s)", channel, len(sources))
|
||||
}
|
||||
|
||||
candidates := bySource[sources[channel-1]]
|
||||
for _, profile := range candidates {
|
||||
if profile.PTZConfiguration != nil {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return candidates[0], nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func testProfile(token string, source string, ptz bool) xsdonvif.Profile {
|
||||
profile := xsdonvif.Profile{Token: xsdonvif.ReferenceToken(token)}
|
||||
if source != "" {
|
||||
sourceToken := xsdonvif.ReferenceToken(source)
|
||||
profile.VideoSourceConfiguration = &xsdonvif.VideoSourceConfiguration{SourceToken: &sourceToken}
|
||||
}
|
||||
if ptz {
|
||||
profile.PTZConfiguration = &xsdonvif.PTZConfiguration{}
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// spe420Profiles mirrors a Hanwha SPE-420: three profiles per channel, token
|
||||
// suffix -(N-1), listed grouped by profile type rather than by channel.
|
||||
func spe420Profiles() []xsdonvif.Profile {
|
||||
var profiles []xsdonvif.Profile
|
||||
for kind := 1; kind <= 3; kind++ {
|
||||
for ch := 0; ch < 4; ch++ {
|
||||
profiles = append(profiles, testProfile(
|
||||
fmt.Sprintf("DefaultProfile-0%d-%d", kind, ch),
|
||||
fmt.Sprintf("VideoSourceToken-%d", ch),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileMapsEncoderChannels(t *testing.T) {
|
||||
profiles := spe420Profiles()
|
||||
for channel := 1; channel <= 4; channel++ {
|
||||
profile, err := selectChannelProfile(profiles, channel)
|
||||
if err != nil {
|
||||
t.Fatalf("channel %d: %v", channel, err)
|
||||
}
|
||||
want := fmt.Sprintf("DefaultProfile-01-%d", channel-1)
|
||||
if string(profile.Token) != want {
|
||||
t.Fatalf("channel %d: got %s, want %s", channel, profile.Token, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfilePrefersProfileWithPTZ(t *testing.T) {
|
||||
profiles := []xsdonvif.Profile{
|
||||
testProfile("mjpeg", "src-0", false),
|
||||
testProfile("main", "src-0", true),
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Token != "main" {
|
||||
t.Fatalf("got %s, want main", profile.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileFallsBackToFirstProfileOfChannel(t *testing.T) {
|
||||
profiles := []xsdonvif.Profile{
|
||||
testProfile("a", "src-0", false),
|
||||
testProfile("b", "src-1", false),
|
||||
testProfile("c", "src-1", false),
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Token != "b" {
|
||||
t.Fatalf("got %s, want b", profile.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileSkipsProfilesWithoutVideoSource(t *testing.T) {
|
||||
profiles := []xsdonvif.Profile{
|
||||
testProfile("audio-only", "", false),
|
||||
testProfile("video", "src-0", false),
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Token != "video" {
|
||||
t.Fatalf("got %s, want video", profile.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileRejectsUnknownChannel(t *testing.T) {
|
||||
if _, err := selectChannelProfile(spe420Profiles(), 5); err == nil {
|
||||
t.Fatal("expected error for channel beyond the device's video sources")
|
||||
}
|
||||
if _, err := selectChannelProfile(spe420Profiles(), 0); err == nil {
|
||||
t.Fatal("expected error for channel 0")
|
||||
}
|
||||
if _, err := selectChannelProfile(nil, 1); err == nil {
|
||||
t.Fatal("expected error when the device has no profiles")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
"github.com/kerberos-io/onvif/imaging"
|
||||
"github.com/kerberos-io/onvif/media"
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Focus control goes through the ONVIF imaging service. The request types of
|
||||
// the onvif library serialise every field (all three move types, every imaging
|
||||
// setting), which cameras reject or which would overwrite unrelated imaging
|
||||
// settings. The types below only carry the fields we want to send. Schema
|
||||
// elements use the "onvif" prefix, which the library declares on the envelope
|
||||
// as http://www.onvif.org/ver10/schema.
|
||||
|
||||
const (
|
||||
focusModeAuto = "AUTO"
|
||||
focusModeManual = "MANUAL"
|
||||
|
||||
// Same move duration as ContinuousZoom.
|
||||
continuousFocusDuration = 500 * time.Millisecond
|
||||
// Step for cameras that only support relative focus moves.
|
||||
relativeFocusDistance = 0.1
|
||||
// Step at full speed for cameras that only support absolute focus moves
|
||||
// (e.g. Axis); scaled by the requested speed.
|
||||
absoluteFocusStep = 0.1
|
||||
// Time to let the camera settle in MANUAL before re-enabling AUTO.
|
||||
autoFocusRetriggerDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
type imagingMoveRequest struct {
|
||||
XMLName xml.Name `xml:"timg:Move"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
Focus focusMove `xml:"timg:Focus"`
|
||||
}
|
||||
|
||||
type focusMove struct {
|
||||
Absolute *absoluteFocusMove `xml:"onvif:Absolute,omitempty"`
|
||||
Relative *relativeFocusMove `xml:"onvif:Relative,omitempty"`
|
||||
Continuous *continuousFocusMove `xml:"onvif:Continuous,omitempty"`
|
||||
}
|
||||
|
||||
type absoluteFocusMove struct {
|
||||
Position float64 `xml:"onvif:Position"`
|
||||
}
|
||||
|
||||
type relativeFocusMove struct {
|
||||
Distance float64 `xml:"onvif:Distance"`
|
||||
Speed float64 `xml:"onvif:Speed,omitempty"`
|
||||
}
|
||||
|
||||
type continuousFocusMove struct {
|
||||
Speed float64 `xml:"onvif:Speed"`
|
||||
}
|
||||
|
||||
type imagingStopRequest struct {
|
||||
XMLName xml.Name `xml:"timg:Stop"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetSettingsRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetImagingSettings"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetSettingsResponse struct {
|
||||
ImagingSettings struct {
|
||||
Brightness *float64 `xml:"Brightness"`
|
||||
Focus struct {
|
||||
AutoFocusMode string `xml:"AutoFocusMode"`
|
||||
} `xml:"Focus"`
|
||||
} `xml:"ImagingSettings"`
|
||||
}
|
||||
|
||||
type imagingSetFocusModeRequest struct {
|
||||
XMLName xml.Name `xml:"timg:SetImagingSettings"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
ImagingSettings struct {
|
||||
Brightness *float64 `xml:"onvif:Brightness,omitempty"`
|
||||
Focus struct {
|
||||
AutoFocusMode string `xml:"onvif:AutoFocusMode"`
|
||||
} `xml:"onvif:Focus"`
|
||||
} `xml:"timg:ImagingSettings"`
|
||||
}
|
||||
|
||||
type imagingGetOptionsRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetOptions"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetOptionsResponse struct {
|
||||
ImagingOptions struct {
|
||||
Focus struct {
|
||||
AutoFocusModes []string `xml:"AutoFocusModes"`
|
||||
} `xml:"Focus"`
|
||||
} `xml:"ImagingOptions"`
|
||||
}
|
||||
|
||||
type imagingGetMoveOptionsRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetMoveOptions"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetMoveOptionsResponse struct {
|
||||
MoveOptions struct {
|
||||
Absolute *struct {
|
||||
Position struct {
|
||||
Min float64 `xml:"Min"`
|
||||
Max float64 `xml:"Max"`
|
||||
} `xml:"Position"`
|
||||
} `xml:"Absolute"`
|
||||
Relative *struct{} `xml:"Relative"`
|
||||
Continuous *struct{} `xml:"Continuous"`
|
||||
} `xml:"MoveOptions"`
|
||||
}
|
||||
|
||||
type imagingGetStatusRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetStatus"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetStatusResponse struct {
|
||||
Status struct {
|
||||
FocusStatus20 *struct {
|
||||
Position *float64 `xml:"Position"`
|
||||
} `xml:"FocusStatus20"`
|
||||
FocusStatus *struct {
|
||||
Position *float64 `xml:"Position"`
|
||||
} `xml:"FocusStatus"`
|
||||
} `xml:"Status"`
|
||||
}
|
||||
|
||||
// focusMoveOptions lists the focus move types a camera supports.
|
||||
type focusMoveOptions struct {
|
||||
Continuous bool
|
||||
Relative bool
|
||||
Absolute bool
|
||||
AbsoluteMin float64
|
||||
AbsoluteMax float64
|
||||
}
|
||||
|
||||
// allFocusMoveOptions is used when a camera does not report its move options:
|
||||
// every move type is tried in turn.
|
||||
var allFocusMoveOptions = focusMoveOptions{Continuous: true, Relative: true, Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1}
|
||||
|
||||
// focusController abstracts the imaging calls so the focus logic can be tested
|
||||
// without a camera.
|
||||
type focusController interface {
|
||||
FocusMode() (string, error)
|
||||
FocusModes() ([]string, error)
|
||||
SetFocusMode(mode string) error
|
||||
MoveOptions() (focusMoveOptions, error)
|
||||
FocusPosition() (float64, error)
|
||||
MoveFocus(move focusMove) error
|
||||
StopFocus() error
|
||||
}
|
||||
|
||||
type onvifFocusClient struct {
|
||||
device *onvif.Device
|
||||
endpoint string
|
||||
token xsdonvif.ReferenceToken
|
||||
}
|
||||
|
||||
func newONVIFFocusClient(device *onvif.Device, channel int) (*onvifFocusClient, error) {
|
||||
endpoint, err := device.GetEndpointByRequestStruct(&imaging.Move{})
|
||||
if err != nil {
|
||||
return nil, errors.New("camera does not expose the ONVIF imaging service: " + err.Error())
|
||||
}
|
||||
token, err := GetVideoSourceTokenFromChannel(device, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &onvifFocusClient{device: device, endpoint: endpoint, token: token}, nil
|
||||
}
|
||||
|
||||
var soapFaultReason = regexp.MustCompile(`<(?:[\w.-]+:)?Text[^>]*>([^<]+)</`)
|
||||
|
||||
// call sends an imaging request and returns the response body. A rejected
|
||||
// request returns an error carrying the camera's SOAP fault reason.
|
||||
func (client *onvifFocusClient) call(operation string, request interface{}) ([]byte, error) {
|
||||
body, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.device.SendSoap(client.endpoint, string(body))
|
||||
if resp == nil {
|
||||
if err == nil {
|
||||
err = errors.New("no response")
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
b, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
reason := http.StatusText(resp.StatusCode)
|
||||
if m := soapFaultReason.FindSubmatch(b); m != nil {
|
||||
reason = string(m[1])
|
||||
}
|
||||
return nil, fmt.Errorf("%s: camera returned HTTP %d: %s", operation, resp.StatusCode, reason)
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, readErr)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) query(operation string, request interface{}, responseNode string, response interface{}) error {
|
||||
b, err := client.call(operation, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decodedXML, et, err := getXMLNode(string(b), responseNode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if err := decodedXML.DecodeElement(response, et); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) imagingSettings() (imagingGetSettingsResponse, error) {
|
||||
var settings imagingGetSettingsResponse
|
||||
err := client.query("get_imaging_settings", imagingGetSettingsRequest{VideoSourceToken: client.token}, "GetImagingSettingsResponse", &settings)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) FocusMode() (string, error) {
|
||||
settings, err := client.imagingSettings()
|
||||
return settings.ImagingSettings.Focus.AutoFocusMode, err
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) SetFocusMode(mode string) error {
|
||||
request := imagingSetFocusModeRequest{VideoSourceToken: client.token}
|
||||
request.ImagingSettings.Focus.AutoFocusMode = mode
|
||||
_, err := client.call("set_focus_mode", request)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some cameras (seen on Axis) fail on a SetImagingSettings that only
|
||||
// carries Focus. Retry with the current brightness, which leaves it as is.
|
||||
settings, settingsErr := client.imagingSettings()
|
||||
if settingsErr != nil || settings.ImagingSettings.Brightness == nil {
|
||||
return err
|
||||
}
|
||||
request.ImagingSettings.Brightness = settings.ImagingSettings.Brightness
|
||||
if _, retryErr := client.call("set_focus_mode", request); retryErr != nil {
|
||||
return errors.Join(err, retryErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) FocusModes() ([]string, error) {
|
||||
var response imagingGetOptionsResponse
|
||||
if err := client.query("get_imaging_options", imagingGetOptionsRequest{VideoSourceToken: client.token}, "GetOptionsResponse", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.ImagingOptions.Focus.AutoFocusModes, nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) MoveOptions() (focusMoveOptions, error) {
|
||||
var response imagingGetMoveOptionsResponse
|
||||
if err := client.query("get_focus_move_options", imagingGetMoveOptionsRequest{VideoSourceToken: client.token}, "GetMoveOptionsResponse", &response); err != nil {
|
||||
return focusMoveOptions{}, err
|
||||
}
|
||||
moveOptions := response.MoveOptions
|
||||
options := focusMoveOptions{
|
||||
Continuous: moveOptions.Continuous != nil,
|
||||
Relative: moveOptions.Relative != nil,
|
||||
Absolute: moveOptions.Absolute != nil,
|
||||
AbsoluteMin: 0,
|
||||
AbsoluteMax: 1,
|
||||
}
|
||||
if moveOptions.Absolute != nil && moveOptions.Absolute.Position.Max > moveOptions.Absolute.Position.Min {
|
||||
options.AbsoluteMin = moveOptions.Absolute.Position.Min
|
||||
options.AbsoluteMax = moveOptions.Absolute.Position.Max
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) FocusPosition() (float64, error) {
|
||||
var response imagingGetStatusResponse
|
||||
if err := client.query("get_focus_status", imagingGetStatusRequest{VideoSourceToken: client.token}, "GetStatusResponse", &response); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
status := response.Status
|
||||
if status.FocusStatus20 != nil && status.FocusStatus20.Position != nil {
|
||||
return *status.FocusStatus20.Position, nil
|
||||
}
|
||||
if status.FocusStatus != nil && status.FocusStatus.Position != nil {
|
||||
return *status.FocusStatus.Position, nil
|
||||
}
|
||||
return 0, errors.New("get_focus_status: camera did not report a focus position")
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) MoveFocus(move focusMove) error {
|
||||
_, err := client.call("focus_move", imagingMoveRequest{VideoSourceToken: client.token, Focus: move})
|
||||
return err
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) StopFocus() error {
|
||||
_, err := client.call("focus_stop", imagingStopRequest{VideoSourceToken: client.token})
|
||||
return err
|
||||
}
|
||||
|
||||
// ContinuousFocus moves the focus motor a short step, the focus equivalent of
|
||||
// ContinuousZoom. Speed is in [-1, 1]: positive focuses near, negative focuses
|
||||
// far. Cameras with continuous focus move for a moment and stop; cameras with
|
||||
// only relative or absolute focus (e.g. Axis) step by an amount scaled to the
|
||||
// speed. The camera is switched to MANUAL focus first, because cameras ignore
|
||||
// manual moves while autofocus is active; use AutoFocus to hand control back.
|
||||
func ContinuousFocus(device *onvif.Device, channel int, speed float64) error {
|
||||
client, err := newONVIFFocusClient(device, channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return continuousFocus(client, speed, continuousFocusDuration)
|
||||
}
|
||||
|
||||
// AutoFocus triggers the camera's native ONVIF autofocus. Setting AUTO while
|
||||
// already in AUTO is a no-op on most cameras, so we toggle through MANUAL to
|
||||
// force a fresh focus cycle.
|
||||
func AutoFocus(device *onvif.Device, channel int) error {
|
||||
client, err := newONVIFFocusClient(device, channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return autoFocus(client, autoFocusRetriggerDelay)
|
||||
}
|
||||
|
||||
func continuousFocus(client focusController, speed float64, duration time.Duration) error {
|
||||
speed = math.Max(-1, math.Min(1, speed))
|
||||
if speed == 0 {
|
||||
return client.StopFocus()
|
||||
}
|
||||
|
||||
if err := ensureFocusMode(client, focusModeManual); err != nil {
|
||||
// Some cameras have no focus mode setting but still accept moves.
|
||||
log.Debug("onvif.continuousFocus(): could not switch to manual focus: " + err.Error())
|
||||
}
|
||||
|
||||
options, err := client.MoveOptions()
|
||||
if err != nil {
|
||||
log.Debug("onvif.continuousFocus(): could not read focus move options, trying all move types: " + err.Error())
|
||||
options = allFocusMoveOptions
|
||||
}
|
||||
|
||||
var moveErrs []error
|
||||
if options.Continuous {
|
||||
err := client.MoveFocus(focusMove{Continuous: &continuousFocusMove{Speed: speed}})
|
||||
if err == nil {
|
||||
time.Sleep(duration)
|
||||
return client.StopFocus()
|
||||
}
|
||||
moveErrs = append(moveErrs, err)
|
||||
}
|
||||
if options.Relative {
|
||||
// A single relative step needs no stop.
|
||||
err := client.MoveFocus(focusMove{Relative: &relativeFocusMove{
|
||||
Distance: math.Copysign(relativeFocusDistance, speed),
|
||||
Speed: math.Abs(speed),
|
||||
}})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
moveErrs = append(moveErrs, err)
|
||||
}
|
||||
if options.Absolute {
|
||||
position, err := client.FocusPosition()
|
||||
if err == nil {
|
||||
target := math.Max(options.AbsoluteMin, math.Min(options.AbsoluteMax, position+speed*absoluteFocusStep))
|
||||
err = client.MoveFocus(focusMove{Absolute: &absoluteFocusMove{Position: target}})
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
moveErrs = append(moveErrs, err)
|
||||
}
|
||||
|
||||
if len(moveErrs) == 0 {
|
||||
return errors.New("camera does not support any focus move")
|
||||
}
|
||||
return errors.Join(moveErrs...)
|
||||
}
|
||||
|
||||
func ensureFocusMode(client focusController, mode string) error {
|
||||
current, err := client.FocusMode()
|
||||
if err == nil && current == mode {
|
||||
return nil
|
||||
}
|
||||
return client.SetFocusMode(mode)
|
||||
}
|
||||
|
||||
var errNoNativeAutoFocus = errors.New("camera does not support native ONVIF autofocus")
|
||||
|
||||
func autoFocus(client focusController, delay time.Duration) error {
|
||||
// Some cameras (seen on Dahua) only advertise MANUAL, yet accept a switch
|
||||
// to AUTO and silently ignore it.
|
||||
if modes, err := client.FocusModes(); err == nil && !slices.Contains(modes, focusModeAuto) {
|
||||
return errNoNativeAutoFocus
|
||||
}
|
||||
|
||||
mode, err := client.FocusMode()
|
||||
if err == nil && mode == focusModeAuto {
|
||||
if err := client.SetFocusMode(focusModeManual); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
if err := client.SetFocusMode(focusModeAuto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mode, err := client.FocusMode(); err == nil && mode != focusModeAuto {
|
||||
return fmt.Errorf("%w: camera accepted AUTO but reports %s", errNoNativeAutoFocus, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVideoSourceTokenFromProfile returns the video source token of a media
|
||||
// profile; the imaging service is addressed by video source, not by profile.
|
||||
func GetVideoSourceTokenFromProfile(device *onvif.Device, profileId int) (xsdonvif.ReferenceToken, error) {
|
||||
var sourceToken xsdonvif.ReferenceToken
|
||||
|
||||
resp, err := device.CallMethod(media.GetProfiles{})
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return sourceToken, err
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return sourceToken, err
|
||||
}
|
||||
|
||||
decodedXML, et, err := getXMLNode(string(b), "GetProfilesResponse")
|
||||
if err != nil {
|
||||
return sourceToken, err
|
||||
}
|
||||
var mProfilesResp media.GetProfilesResponse
|
||||
if err := decodedXML.DecodeElement(&mProfilesResp, et); err != nil {
|
||||
return sourceToken, err
|
||||
}
|
||||
|
||||
for i, profile := range mProfilesResp.Profiles {
|
||||
if i == profileId && profile.VideoSourceConfiguration != nil && profile.VideoSourceConfiguration.SourceToken != nil {
|
||||
return *profile.VideoSourceConfiguration.SourceToken, nil
|
||||
}
|
||||
}
|
||||
return sourceToken, errors.New("no video source token found for profile")
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeFocusClient struct {
|
||||
mode string
|
||||
modeErr error
|
||||
focusModes []string
|
||||
focusModesErr error
|
||||
ignoreAuto bool
|
||||
setModeErr error
|
||||
options *focusMoveOptions
|
||||
optionsErr error
|
||||
position float64
|
||||
positionErr error
|
||||
continuousErr error
|
||||
relativeErr error
|
||||
absoluteErr error
|
||||
modes []string
|
||||
moves []focusMove
|
||||
stopCalls int
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) FocusMode() (string, error) {
|
||||
return client.mode, client.modeErr
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) FocusModes() ([]string, error) {
|
||||
if client.focusModes == nil && client.focusModesErr == nil {
|
||||
return []string{focusModeManual, focusModeAuto}, nil
|
||||
}
|
||||
return client.focusModes, client.focusModesErr
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) SetFocusMode(mode string) error {
|
||||
client.modes = append(client.modes, mode)
|
||||
if client.setModeErr != nil {
|
||||
return client.setModeErr
|
||||
}
|
||||
if mode == focusModeAuto && client.ignoreAuto {
|
||||
return nil
|
||||
}
|
||||
client.mode = mode
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) MoveOptions() (focusMoveOptions, error) {
|
||||
if client.optionsErr != nil {
|
||||
return focusMoveOptions{}, client.optionsErr
|
||||
}
|
||||
if client.options == nil {
|
||||
return allFocusMoveOptions, nil
|
||||
}
|
||||
return *client.options, nil
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) FocusPosition() (float64, error) {
|
||||
return client.position, client.positionErr
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) MoveFocus(move focusMove) error {
|
||||
client.moves = append(client.moves, move)
|
||||
switch {
|
||||
case move.Continuous != nil:
|
||||
return client.continuousErr
|
||||
case move.Relative != nil:
|
||||
return client.relativeErr
|
||||
default:
|
||||
return client.absoluteErr
|
||||
}
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) StopFocus() error {
|
||||
client.stopCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestContinuousFocusSwitchesToManualMovesAndStops(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeAuto}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
|
||||
if len(client.modes) != 1 || client.modes[0] != focusModeManual {
|
||||
t.Fatalf("modes = %v, want [MANUAL]", client.modes)
|
||||
}
|
||||
if len(client.moves) != 1 || client.moves[0].Continuous == nil || client.moves[0].Continuous.Speed != 0.5 {
|
||||
t.Fatalf("moves = %+v, want one continuous move at 0.5", client.moves)
|
||||
}
|
||||
if client.stopCalls != 1 {
|
||||
t.Fatalf("stopCalls = %d, want 1", client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusKeepsManualMode(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.modes) != 0 {
|
||||
t.Fatalf("modes = %v, want no mode change when already MANUAL", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusClampsSpeed(t *testing.T) {
|
||||
client := &fakeFocusClient{}
|
||||
|
||||
if err := continuousFocus(client, -3, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if got := client.moves[0].Continuous.Speed; got != -1 {
|
||||
t.Fatalf("speed = %v, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusIgnoresUnsupportedFocusMode(t *testing.T) {
|
||||
client := &fakeFocusClient{setModeErr: errors.New("not supported")}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 1 || client.stopCalls != 1 {
|
||||
t.Fatalf("moves = %d stopCalls = %d, want 1 and 1", len(client.moves), client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusFallsBackToRelative(t *testing.T) {
|
||||
client := &fakeFocusClient{continuousErr: errors.New("continuous not supported")}
|
||||
|
||||
if err := continuousFocus(client, -0.4, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 2 || client.moves[1].Relative == nil {
|
||||
t.Fatalf("moves = %+v, want continuous then relative", client.moves)
|
||||
}
|
||||
relative := client.moves[1].Relative
|
||||
if relative.Distance != -relativeFocusDistance || relative.Speed != 0.4 {
|
||||
t.Fatalf("relative = %+v, want distance %v speed 0.4", relative, -relativeFocusDistance)
|
||||
}
|
||||
if client.stopCalls != 0 {
|
||||
t.Fatalf("stopCalls = %d, want 0 for a relative move", client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusUsesAbsoluteWhenOnlyAbsoluteSupported(t *testing.T) {
|
||||
// Axis cameras report only absolute focus moves.
|
||||
client := &fakeFocusClient{
|
||||
mode: focusModeManual,
|
||||
options: &focusMoveOptions{Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1},
|
||||
position: 0.48,
|
||||
}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 1 || client.moves[0].Absolute == nil {
|
||||
t.Fatalf("moves = %+v, want a single absolute move", client.moves)
|
||||
}
|
||||
if got, want := client.moves[0].Absolute.Position, 0.48+0.5*absoluteFocusStep; math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("position = %v, want %v", got, want)
|
||||
}
|
||||
if client.stopCalls != 0 {
|
||||
t.Fatalf("stopCalls = %d, want 0 for an absolute move", client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusClampsAbsoluteToRange(t *testing.T) {
|
||||
client := &fakeFocusClient{
|
||||
options: &focusMoveOptions{Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1},
|
||||
position: 0.02,
|
||||
}
|
||||
|
||||
if err := continuousFocus(client, -1, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if got := client.moves[0].Absolute.Position; got != 0 {
|
||||
t.Fatalf("position = %v, want clamped to 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusTriesAllMovesWithoutOptions(t *testing.T) {
|
||||
client := &fakeFocusClient{
|
||||
optionsErr: errors.New("no move options"),
|
||||
continuousErr: errors.New("continuous not supported"),
|
||||
relativeErr: errors.New("relative not supported"),
|
||||
position: 0.5,
|
||||
}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 3 || client.moves[2].Absolute == nil {
|
||||
t.Fatalf("moves = %+v, want continuous, relative then absolute", client.moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusReturnsErrorWhenAllMovesFail(t *testing.T) {
|
||||
client := &fakeFocusClient{
|
||||
continuousErr: errors.New("continuous not supported"),
|
||||
relativeErr: errors.New("relative not supported"),
|
||||
positionErr: errors.New("no position"),
|
||||
}
|
||||
|
||||
err := continuousFocus(client, 0.5, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "continuous") || !strings.Contains(err.Error(), "relative") || !strings.Contains(err.Error(), "position") {
|
||||
t.Fatalf("continuousFocus() error = %v, want every move error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusReturnsErrorWhenNoMoveSupported(t *testing.T) {
|
||||
client := &fakeFocusClient{options: &focusMoveOptions{}}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err == nil {
|
||||
t.Fatal("continuousFocus() error = nil, want an error")
|
||||
}
|
||||
if len(client.moves) != 0 {
|
||||
t.Fatalf("moves = %+v, want none", client.moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusZeroSpeedStops(t *testing.T) {
|
||||
client := &fakeFocusClient{}
|
||||
|
||||
if err := continuousFocus(client, 0, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 0 || len(client.modes) != 0 || client.stopCalls != 1 {
|
||||
t.Fatalf("moves = %d modes = %v stopCalls = %d, want only a stop", len(client.moves), client.modes, client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusRetriggersWhenAlreadyAuto(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeAuto}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "MANUAL,AUTO" {
|
||||
t.Fatalf("modes = %v, want [MANUAL AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusFromManual(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "AUTO" {
|
||||
t.Fatalf("modes = %v, want [AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusWhenModeUnreadable(t *testing.T) {
|
||||
client := &fakeFocusClient{modeErr: errors.New("unreadable")}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "AUTO" {
|
||||
t.Fatalf("modes = %v, want [AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusRejectsCameraWithoutAutoMode(t *testing.T) {
|
||||
// Dahua cameras advertise only MANUAL.
|
||||
client := &fakeFocusClient{mode: focusModeManual, focusModes: []string{focusModeManual}}
|
||||
|
||||
if err := autoFocus(client, 0); !errors.Is(err, errNoNativeAutoFocus) {
|
||||
t.Fatalf("autoFocus() error = %v, want errNoNativeAutoFocus", err)
|
||||
}
|
||||
if len(client.modes) != 0 {
|
||||
t.Fatalf("modes = %v, want no mode change", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusDetectsIgnoredAutoMode(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual, ignoreAuto: true}
|
||||
|
||||
if err := autoFocus(client, 0); !errors.Is(err, errNoNativeAutoFocus) {
|
||||
t.Fatalf("autoFocus() error = %v, want errNoNativeAutoFocus", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusWhenFocusModesUnreadable(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual, focusModesErr: errors.New("no options")}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "AUTO" {
|
||||
t.Fatalf("modes = %v, want [AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagingRequestsOnlyCarryFocusFields(t *testing.T) {
|
||||
move, err := xml.Marshal(imagingMoveRequest{
|
||||
VideoSourceToken: "source",
|
||||
Focus: focusMove{Continuous: &continuousFocusMove{Speed: 0.5}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal move: %v", err)
|
||||
}
|
||||
wantMove := "<timg:Move><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:Focus><onvif:Continuous><onvif:Speed>0.5</onvif:Speed></onvif:Continuous></timg:Focus></timg:Move>"
|
||||
if string(move) != wantMove {
|
||||
t.Fatalf("move XML = %s\nwant %s", move, wantMove)
|
||||
}
|
||||
|
||||
absolute, err := xml.Marshal(imagingMoveRequest{
|
||||
VideoSourceToken: "source",
|
||||
Focus: focusMove{Absolute: &absoluteFocusMove{Position: 0.53}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal absolute move: %v", err)
|
||||
}
|
||||
wantAbsolute := "<timg:Move><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:Focus><onvif:Absolute><onvif:Position>0.53</onvif:Position></onvif:Absolute></timg:Focus></timg:Move>"
|
||||
if string(absolute) != wantAbsolute {
|
||||
t.Fatalf("absolute move XML = %s\nwant %s", absolute, wantAbsolute)
|
||||
}
|
||||
|
||||
request := imagingSetFocusModeRequest{VideoSourceToken: "source"}
|
||||
request.ImagingSettings.Focus.AutoFocusMode = focusModeManual
|
||||
settings, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
wantSettings := "<timg:SetImagingSettings><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:ImagingSettings><onvif:Focus><onvif:AutoFocusMode>MANUAL</onvif:AutoFocusMode></onvif:Focus></timg:ImagingSettings></timg:SetImagingSettings>"
|
||||
if string(settings) != wantSettings {
|
||||
t.Fatalf("settings XML = %s\nwant %s", settings, wantSettings)
|
||||
}
|
||||
|
||||
// Brightness must come before Focus (schema order) for the Axis retry.
|
||||
brightness := 50.0
|
||||
request.ImagingSettings.Brightness = &brightness
|
||||
settings, err = xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings with brightness: %v", err)
|
||||
}
|
||||
wantSettings = "<timg:SetImagingSettings><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:ImagingSettings><onvif:Brightness>50</onvif:Brightness><onvif:Focus><onvif:AutoFocusMode>MANUAL</onvif:AutoFocusMode></onvif:Focus></timg:ImagingSettings></timg:SetImagingSettings>"
|
||||
if string(settings) != wantSettings {
|
||||
t.Fatalf("settings XML = %s\nwant %s", settings, wantSettings)
|
||||
}
|
||||
}
|
||||
|
||||
const testImagingEnvelope = `<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:timg="http://www.onvif.org/ver20/imaging/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema"><SOAP-ENV:Body>%s</SOAP-ENV:Body></SOAP-ENV:Envelope>`
|
||||
|
||||
func decodeTestImagingResponse(t *testing.T, body string, node string, out interface{}) {
|
||||
t.Helper()
|
||||
decoder, start, err := getXMLNode(strings.Replace(testImagingEnvelope, "%s", body, 1), node)
|
||||
if err != nil {
|
||||
t.Fatalf("getXMLNode() error = %v", err)
|
||||
}
|
||||
if err := decoder.DecodeElement(out, start); err != nil {
|
||||
t.Fatalf("DecodeElement() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagingSettingsResponseDecodesFocusModeAndBrightness(t *testing.T) {
|
||||
var settings imagingGetSettingsResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetImagingSettingsResponse><timg:ImagingSettings><tt:Brightness>50</tt:Brightness><tt:Focus><tt:AutoFocusMode>AUTO</tt:AutoFocusMode></tt:Focus></timg:ImagingSettings></timg:GetImagingSettingsResponse>`, "GetImagingSettingsResponse", &settings)
|
||||
|
||||
if settings.ImagingSettings.Focus.AutoFocusMode != focusModeAuto {
|
||||
t.Fatalf("AutoFocusMode = %q, want AUTO", settings.ImagingSettings.Focus.AutoFocusMode)
|
||||
}
|
||||
if settings.ImagingSettings.Brightness == nil || *settings.ImagingSettings.Brightness != 50 {
|
||||
t.Fatalf("Brightness = %v, want 50", settings.ImagingSettings.Brightness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagingOptionsResponseDecodesFocusModes(t *testing.T) {
|
||||
// Response captured from a Dahua camera.
|
||||
var response imagingGetOptionsResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetOptionsResponse><timg:ImagingOptions><tt:Brightness><tt:Min>0</tt:Min><tt:Max>100</tt:Max></tt:Brightness><tt:Focus><tt:AutoFocusModes>MANUAL</tt:AutoFocusModes><tt:DefaultSpeed><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:DefaultSpeed></tt:Focus></timg:ImagingOptions></timg:GetOptionsResponse>`, "GetOptionsResponse", &response)
|
||||
|
||||
if got := strings.Join(response.ImagingOptions.Focus.AutoFocusModes, ","); got != "MANUAL" {
|
||||
t.Fatalf("AutoFocusModes = %q, want MANUAL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOptionsResponseDecodesAbsoluteOnly(t *testing.T) {
|
||||
// Response captured from an Axis camera.
|
||||
var response imagingGetMoveOptionsResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetMoveOptionsResponse><timg:MoveOptions><tt:Absolute><tt:Position><tt:Min>0</tt:Min><tt:Max>1</tt:Max></tt:Position></tt:Absolute></timg:MoveOptions></timg:GetMoveOptionsResponse>`, "GetMoveOptionsResponse", &response)
|
||||
|
||||
options := response.MoveOptions
|
||||
if options.Absolute == nil || options.Relative != nil || options.Continuous != nil {
|
||||
t.Fatalf("options = %+v, want only absolute", options)
|
||||
}
|
||||
if options.Absolute.Position.Min != 0 || options.Absolute.Position.Max != 1 {
|
||||
t.Fatalf("absolute range = %+v, want 0..1", options.Absolute.Position)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusResponseDecodesFocusPosition(t *testing.T) {
|
||||
// Response captured from an Axis camera.
|
||||
var response imagingGetStatusResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetStatusResponse><timg:Status><tt:FocusStatus20><tt:Position>0.479226589</tt:Position><tt:MoveStatus>IDLE</tt:MoveStatus></tt:FocusStatus20></timg:Status></timg:GetStatusResponse>`, "GetStatusResponse", &response)
|
||||
|
||||
if response.Status.FocusStatus20 == nil || response.Status.FocusStatus20.Position == nil || *response.Status.FocusStatus20.Position != 0.479226589 {
|
||||
t.Fatalf("status = %+v, want position 0.479226589", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOAPFaultReasonExtraction(t *testing.T) {
|
||||
fault := `<SOAP-ENV:Fault><SOAP-ENV:Reason><SOAP-ENV:Text xml:lang="en">Argument value invalid</SOAP-ENV:Text></SOAP-ENV:Reason></SOAP-ENV:Fault>`
|
||||
m := soapFaultReason.FindStringSubmatch(fault)
|
||||
if m == nil || m[1] != "Argument value invalid" {
|
||||
t.Fatalf("fault reason = %v, want Argument value invalid", m)
|
||||
}
|
||||
}
|
||||
+35
-14
@@ -119,10 +119,30 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
|
||||
// Connect to Onvif device
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, _, err := ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
if err == nil && (onvifAction.Action == "focus" || onvifAction.Action == "autofocus") {
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Focus goes through the imaging service, so it also works on
|
||||
// cameras without PTZ (e.g. fixed cameras with a motorised lens).
|
||||
if onvifAction.Action == "focus" {
|
||||
err := ContinuousFocus(device, cameraConfiguration.ONVIFChannel, ptzAction.Focus)
|
||||
if err != nil {
|
||||
log.Error("onvif.HandleONVIFActions() - ContinuousFocus: " + err.Error())
|
||||
} else {
|
||||
log.Info("onvif.HandleONVIFActions() - ContinuousFocus: successfully focused camera")
|
||||
}
|
||||
} else {
|
||||
err := AutoFocus(device, cameraConfiguration.ONVIFChannel)
|
||||
if err != nil {
|
||||
log.Error("onvif.HandleONVIFActions() - AutoFocus: " + err.Error())
|
||||
} else {
|
||||
log.Info("onvif.HandleONVIFActions() - AutoFocus: successfully triggered autofocus")
|
||||
}
|
||||
}
|
||||
|
||||
} else if err == nil {
|
||||
|
||||
// Get the profile token of the configured channel
|
||||
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
|
||||
// Get the configurations from the device
|
||||
@@ -154,7 +174,7 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
|
||||
|
||||
// Execute the preset
|
||||
preset := ptzAction.Preset
|
||||
err := GoToPresetFromDevice(device, preset)
|
||||
err := GoToPresetFromDevice(device, cameraConfiguration.ONVIFChannel, preset)
|
||||
if err != nil {
|
||||
log.Debug("onvif.HandleONVIFActions() - GotoPreset: " + err.Error())
|
||||
} else {
|
||||
@@ -349,8 +369,8 @@ func GetPositionFromDevice(configuration models.Configuration) (xsdonvif.PTZVect
|
||||
device, _, err := ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the configured channel
|
||||
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
// Get the PTZ configurations from the device
|
||||
position, err := GetPosition(device, token)
|
||||
@@ -863,12 +883,12 @@ func GetCapabilitiesFromDevice(dev *onvif.Device) []string {
|
||||
return capabilities
|
||||
}
|
||||
|
||||
func GetPresetsFromDevice(device *onvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
func GetPresetsFromDevice(device *onvif.Device, channel int) ([]models.OnvifActionPreset, error) {
|
||||
var presets []models.OnvifActionPreset
|
||||
var presetsResponse ptz.GetPresetsResponse
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := GetTokenFromChannel(device, channel)
|
||||
if err == nil {
|
||||
resp, err := device.CallMethod(ptz.GetPresets{
|
||||
ProfileToken: token,
|
||||
@@ -913,11 +933,11 @@ func GetPresetsFromDevice(device *onvif.Device) ([]models.OnvifActionPreset, err
|
||||
return presets, err
|
||||
}
|
||||
|
||||
func GoToPresetFromDevice(device *onvif.Device, presetName string) error {
|
||||
func GoToPresetFromDevice(device *onvif.Device, channel int, presetName string) error {
|
||||
var goToPresetResponse ptz.GotoPresetResponse
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := GetTokenFromChannel(device, channel)
|
||||
if err == nil {
|
||||
preset := xsdonvif.ReferenceToken(presetName)
|
||||
resp, err := device.CallMethod(ptz.GotoPreset{
|
||||
@@ -1020,6 +1040,7 @@ func VerifyOnvifConnection(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1028,8 +1049,8 @@ func VerifyOnvifConnection(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, capabilities, err := ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"device": device,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -74,6 +75,7 @@ func LoginToOnvif(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -82,8 +84,8 @@ func LoginToOnvif(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, capabilities, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := onvif.GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"device": device,
|
||||
@@ -128,6 +130,7 @@ func GetOnvifCapabilities(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -172,6 +175,7 @@ func DoOnvifPanTilt(c *gin.Context) {
|
||||
ONVIFXAddr: onvifPanTilt.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifPanTilt.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifPanTilt.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifPanTilt.OnvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -181,8 +185,8 @@ func DoOnvifPanTilt(c *gin.Context) {
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := onvif.GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -246,6 +250,7 @@ func DoOnvifZoom(c *gin.Context) {
|
||||
ONVIFXAddr: onvifZoom.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifZoom.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifZoom.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifZoom.OnvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -255,8 +260,8 @@ func DoOnvifZoom(c *gin.Context) {
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := onvif.GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -298,6 +303,54 @@ func DoOnvifZoom(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// DoOnvifFocus godoc
|
||||
// @Router /api/camera/onvif/focus [post]
|
||||
// @ID camera-onvif-focus
|
||||
// @Tags onvif
|
||||
// @Param focus body models.OnvifFocus true "OnvifFocus"
|
||||
// @Summary Focusing the camera near or far.
|
||||
// @Description Focusing the camera near (positive) or far (negative).
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func DoOnvifFocus(c *gin.Context) {
|
||||
var onvifFocus models.OnvifFocus
|
||||
err := c.BindJSON(&onvifFocus)
|
||||
if err == nil && onvifFocus.OnvifCredentials.ONVIFXAddr == "" {
|
||||
err = errors.New("missing onvif_xaddr")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
|
||||
cameraConfiguration := models.IPCamera{
|
||||
ONVIFXAddr: onvifFocus.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifFocus.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifFocus.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifFocus.OnvifCredentials.ONVIFChannel,
|
||||
}
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
|
||||
if err == nil {
|
||||
err := onvif.ContinuousFocus(device, cameraConfiguration.ONVIFChannel, onvifFocus.Focus)
|
||||
if err == nil {
|
||||
c.JSON(200, models.APIResponse{
|
||||
Message: "Successfully focused the camera",
|
||||
})
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Message: "Something went wrong: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Message: "Something went wrong: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Message: "Something went wrong: " + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetOnvifPresets godoc
|
||||
// @Router /api/camera/onvif/presets [post]
|
||||
// @ID camera-onvif-presets
|
||||
@@ -319,6 +372,7 @@ func GetOnvifPresets(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -327,7 +381,7 @@ func GetOnvifPresets(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
presets, err := onvif.GetPresetsFromDevice(device)
|
||||
presets, err := onvif.GetPresetsFromDevice(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"presets": presets,
|
||||
@@ -370,6 +424,7 @@ func GoToOnvifPreset(c *gin.Context) {
|
||||
ONVIFXAddr: onvifPreset.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifPreset.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifPreset.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifPreset.OnvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -378,7 +433,7 @@ func GoToOnvifPreset(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
err := onvif.GoToPresetFromDevice(device, onvifPreset.Preset)
|
||||
err := onvif.GoToPresetFromDevice(device, cameraConfiguration.ONVIFChannel, onvifPreset.Preset)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"data": "Camera preset activated: " + onvifPreset.Preset,
|
||||
@@ -425,6 +480,7 @@ func DoGetDigitalInputs(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -514,6 +570,7 @@ func DoGetRelayOutputs(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -587,6 +644,7 @@ func DoTriggerRelayOutput(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
|
||||
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
|
||||
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
|
||||
api.POST("/camera/onvif/zoom", DoOnvifZoom)
|
||||
api.POST("/camera/onvif/focus", DoOnvifFocus)
|
||||
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
|
||||
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
|
||||
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
|
||||
|
||||
Reference in New Issue
Block a user