Compare commits

...
Author SHA1 Message Date
Cédric Verstraeten 8016b3ba93 Merge pull request #349 from kerberos-io/cedricve-fix-v3-13-2-recording-regression
fix: prevent realtime-processing panic without MQTT credentials
2026-11-04 16:25:24 +01:00
Cédric VerstraetenandCopilot App 4dc6fe7e18 fix: skip realtime processing without MQTT client
Avoid dereferencing the nil client returned when Hub credentials are absent. Log the disabled worker and cover enabled, disabled, and offline configurations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-04 16:20:30 +01:00
Cédric Verstraeten 553fd8e065 Merge pull request #347 from EpicMouse123/feature/onvif-channel
Add ONVIF channel selection for multi-channel devices
2026-11-04 15:39:59 +01:00
adam.witwickiandClaude Opus 5.5 e1b886edd8 Add ONVIF channel selection for multi-channel devices
Encoders and NVRs expose several media profiles per video input, so
always using the first profile drives the camera on channel 1. Add a
1-based onvif_channel setting (config file, configmap, MongoDB, env var
AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL, and onvif_credentials in the HTTP
API). The channel is resolved by grouping GetProfiles results by video
source; 0/unset keeps the existing first-profile behaviour.

PTZ, zoom, presets, position and focus now use the configured channel,
and the Hub heartbeat reads presets from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:11:50 +00:00
Cédric Verstraeten 24bcc296f1 Merge pull request #345 from kerberos-io/fix/preserve-tus-failures
fix/preserve-tus-failures
2026-10-21 21:45:05 +02:00
Cédric Verstraeten 42e67599ab Preserve TUS sessions and latest retry errors
Keep successful upload creation sessions usable when the optional response body is interrupted, while still requiring Location. Preserve the latest failure across retries and add regression coverage and documentation.
2026-10-21 21:34:25 +02:00
Cédric Verstraeten f64b762eee Merge pull request #344 from kerberos-io/fix/tus-minio-upload-empty-region
fix/tus-minio-upload-empty-region?expand=1
2026-10-21 21:24:03 +02:00
Cédric Verstraeten 9c754b9b91 Preserve resumable upload failure details
Include up to 4 KiB of server error responses and retain the latest failure cause when retries are exhausted. Add coverage for Hub and Vault creation failures and document provider troubleshooting and the temporary ordinary-upload workaround.
2026-10-21 21:19:20 +02:00
Cédric Verstraeten 2f13af62ef Merge pull request #343 from kerberos-io/fix/add-walk-dir
fix/add-walk-dir
2026-10-21 19:39:40 +02:00
adam.witwicki 1445789694 Add ONVIF focus control over MQTT and HTTP
Add "focus" and "autofocus" ONVIF actions, sent through navigate-ptz in
the same shape as zoom:

  {"action":"focus","payload":{"focus":0.5}}   positive = near, -1..1
  {"action":"autofocus","payload":{}}

Focus goes through the ONVIF imaging service and is handled before the
PTZ configuration lookup, so it also works on fixed cameras with a
motorised lens. A focus nudge switches the camera to MANUAL focus;
autofocus hands control back.

Cameras differ in which focus moves they support, so the move type is
picked from GetMoveOptions: a continuous move for 500 ms then stop (as
ContinuousZoom), else a relative step, else an absolute step from the
current lens position (Axis only supports absolute moves).

Autofocus toggles MANUAL -> AUTO to force a fresh focus cycle. It
reports an error when the camera does not advertise AUTO, or accepts
AUTO but keeps reporting MANUAL (seen on Dahua).

The imaging requests carry only the focus fields, because the onvif
library's request types serialise every field. Some Axis firmware
rejects a SetImagingSettings that only carries Focus, so that request is
retried once with the current brightness. Rejected requests include the
camera's SOAP fault reason in the error.

Also add POST /api/camera/onvif/focus, mirroring /api/camera/onvif/zoom.

Hub: needs an adjustable focus step. How far one nudge moves the lens
differs a lot between cameras: one full-speed nudge crossed the whole
focus range on an Avigilon, while three barely moved another camera.
The agent uses the focus value (-1..1) as the speed / step size, so Hub
should let users set it (e.g. a focus step or speed control) instead of
sending a fixed value.

Tested against Axis, Dahua, Avigilon and two other ONVIF cameras.
2026-10-19 12:52:32 +01:00
17 changed files with 1466 additions and 30 deletions
+12
View File
@@ -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
+6 -1
View File
@@ -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 != "" {
+4 -4
View File
@@ -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)
}
})
}
}
+30 -3
View File
@@ -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")
+141
View File
@@ -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 })
+3
View File
@@ -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":
+6
View File
@@ -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"`
+1
View File
@@ -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"`
+1
View File
@@ -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"`
+113
View File
@@ -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
}
+105
View File
@@ -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")
}
}
+466
View File
@@ -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")
}
+418
View File
@@ -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
View File
@@ -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,
+66 -8
View File
@@ -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,
},
},
},
+1
View File
@@ -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)