mirror of
https://github.com/kerberos-io/agent.git
synced 2026-11-05 20:20:23 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8016b3ba93 | ||
|
|
4dc6fe7e18 | ||
|
|
553fd8e065 | ||
|
|
e1b886edd8 | ||
|
|
24bcc296f1 | ||
|
|
42e67599ab | ||
|
|
f64b762eee | ||
|
|
9c754b9b91 | ||
|
|
2f13af62ef | ||
|
|
64e6901574 | ||
|
|
c70253ddef | ||
|
|
04497dd877 | ||
|
|
3bd195834b | ||
|
|
1445789694 | ||
|
|
949762c91b | ||
|
|
b805b84d0a | ||
|
|
a57b6310b2 | ||
|
|
2ec6faffe1 | ||
|
|
d40c544584 | ||
|
|
02ed31da1f | ||
|
|
1db00728c9 | ||
|
|
58095365a9 |
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8080
|
||||
8082
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
|
||||
}
|
||||
Vendored
+1
-1
@@ -14,7 +14,7 @@
|
||||
"-action",
|
||||
"run",
|
||||
"-port",
|
||||
"8080"
|
||||
"8082"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/machinery/.env.local",
|
||||
"buildFlags": "--tags dynamic,moq",
|
||||
|
||||
@@ -76,6 +76,60 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
|
||||
1. [What is new?](#what-is-new)
|
||||
1. [Contributors](#contributors)
|
||||
|
||||
### Direct S3 recording layout
|
||||
|
||||
Direct S3 uploads store recordings as
|
||||
`<s3.username>/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4`. The timestamp is the
|
||||
recording's Unix timestamp in milliseconds, and the date is its UTC date, not the upload
|
||||
date. Timestamp and device key come from the recording's `.metadata` upload
|
||||
checkpoint, which stores the timestamp in milliseconds. The uploader also
|
||||
preserves checkpoint duration, FPS (when available), and encryption status as S3
|
||||
object metadata. The device key must be non-empty and contain no path separators.
|
||||
Retried uploads use the same object key, even after midnight. Millisecond
|
||||
precision keeps recordings starting within the same second distinct.
|
||||
|
||||
This replaces the previous `<s3.username>/<filename>.mp4` layout for new uploads.
|
||||
Existing objects are not moved; consumers of direct S3 uploads must support both
|
||||
layouts when reading historical recordings. Updated Kerberos Vault deployments
|
||||
use the same filename and dated layout under the authenticated account directory
|
||||
for both direct Vault uploads and Hub-proxied uploads.
|
||||
|
||||
Only historical recordings with missing, empty, or FPS-only queue markers fall
|
||||
back to the legacy filename format and configured Agent key. A structured
|
||||
checkpoint takes precedence over both the filename and current configured key;
|
||||
an invalid or unreadable checkpoint is reported as an upload error, not silently
|
||||
replaced with filename-derived values.
|
||||
For older structured checkpoints, missing camera name, region, and change count
|
||||
are recovered from a validated legacy filename. Populated checkpoint fields
|
||||
take precedence, and the checkpoint timestamp and device key remain authoritative.
|
||||
|
||||
The shorter filename cannot be decoded by legacy filename-only consumers.
|
||||
Integrations must preserve structured recording metadata, including the device
|
||||
key (`productid`) and recording timestamp (`event-timestamp`), as well as the full
|
||||
object key.
|
||||
|
||||
### Local recording layout
|
||||
|
||||
New local recordings are stored under
|
||||
`data/recordings/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4`, using the recording's UTC
|
||||
start date and Unix-millisecond timestamp. For example:
|
||||
`data/recordings/2026/01/02/1767355200123-device-key.mp4`.
|
||||
The filename no longer embeds the camera name, region, change count, or duration,
|
||||
and it does not change when the recording is finalized.
|
||||
Those attributes, together with FPS and encryption status, are captured in the
|
||||
`.metadata` upload checkpoint and sent as structured HTTP/tus metadata.
|
||||
|
||||
Existing flat recordings and dated recordings with older long filenames remain
|
||||
supported without a migration. Recording lists, day filters, playback, uploaders,
|
||||
and automatic cleanup handle both filename formats and layouts. Upload queue
|
||||
checkpoints remain flat in `data/cloud/`. Successful-upload removal also resolves
|
||||
both local layouts. Deploy the updated Vault alongside Agent for canonical
|
||||
`<account-directory>/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4` storage keys and
|
||||
metadata-driven Hub integration events.
|
||||
Directory decryption also processes dated subdirectories and writes each output
|
||||
to a sibling `decrypted/` directory; those outputs are excluded from recording
|
||||
lists and automatic cleanup.
|
||||
|
||||
## Quickstart - Docker
|
||||
|
||||
The easiest way to get your Kerberos Agent up and running is to use our public image on [Docker hub](https://hub.docker.com/r/kerberos/agent). Once you have selected a specific tag, run `docker` command below, which will open the web interface of your Kerberos agent on port `80`, and off you go. For a more configurable and persistent deployment have a look at [Running and automating a Kerberos Agent](#running-and-automating-a-kerberos-agent).
|
||||
@@ -330,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" |
|
||||
@@ -410,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
|
||||
@@ -602,7 +668,10 @@ The `web` is a **React** project which is the main entry point for an end user t
|
||||
cd ui
|
||||
yarn start
|
||||
|
||||
This will start a webserver and launches the web app on port `3000`.
|
||||
This starts the web app on port `3000`. In development, the UI connects to the
|
||||
Agent API on port `8082`. Override that port when needed:
|
||||
|
||||
REACT_APP_AGENT_API_PORT=9090 yarn start
|
||||
|
||||

|
||||
|
||||
@@ -618,9 +687,14 @@ You can simply run the `machinery` using following commands.
|
||||
|
||||
git clone https://github.com/kerberos-io/agent
|
||||
cd machinery
|
||||
go run main.go -action run -port 80
|
||||
go run main.go -action run -port 8082
|
||||
|
||||
This will launch the Kerberos Agent and run a webserver on port `80`. You can change the port by your own preference. We strongly support the usage of [Goland](https://www.jetbrains.com/go/) or [Visual Studio Code](https://code.visualstudio.com/), as it comes with all the debugging and linting features built in.
|
||||
This will launch the Kerberos Agent and run a webserver on port `8082`, matching
|
||||
the UI development default. You can change the port together with
|
||||
`REACT_APP_AGENT_API_PORT`. We strongly support the usage of
|
||||
[Goland](https://www.jetbrains.com/go/) or
|
||||
[Visual Studio Code](https://code.visualstudio.com/), as it comes with all the
|
||||
debugging and linting features built in.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -10,11 +10,58 @@ import (
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestPickRecordingToCleanupAcrossDatedDirectories(t *testing.T) {
|
||||
for _, legacyMarker := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "metadata", true: "legacy"}[legacyMarker], func(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
pendingName := "2026/01/01/oldest_pending.mp4"
|
||||
safeName := "2026/01/02/uploaded.mp4"
|
||||
writeRecording(t, recordingsDir, pendingName, 30)
|
||||
if legacyMarker {
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, filepath.Base(pendingName)), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
markPending(t, cloudDir, pendingName)
|
||||
}
|
||||
writeRecording(t, recordingsDir, safeName, 20)
|
||||
writeRecording(t, recordingsDir, "flat_uploaded.mp4", 10)
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil || pending || name != safeName {
|
||||
t.Fatalf("cleanup = (%q, %v, %v), want (%q, false, nil)", name, pending, err, safeName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDatedPendingRecordingRemovesFlatMarker(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
name := "2026/01/01/pending.mp4"
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(recordingsDir, name)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSizedRecording(t, recordingsDir, name, 2*1000*1000)
|
||||
markPending(t, cloudDir, name)
|
||||
config := &models.Configuration{Config: models.Config{AutoClean: "true", MaxDirectorySize: 1}}
|
||||
CleanupRecordingDirectory(filepath.Dir(filepath.Dir(recordingsDir)), config)
|
||||
for _, filePath := range []string{
|
||||
filepath.Join(recordingsDir, name),
|
||||
filepath.Join(cloudDir, models.RecordingUploadMetadataFileName(name)),
|
||||
} {
|
||||
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be removed, got %v", filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeRecording creates a file under recordingsDir and sets its modtime so the
|
||||
// tests can control the "oldest" ordering deterministically.
|
||||
func writeRecording(t *testing.T, recordingsDir, name string, ageMinutes int) {
|
||||
t.Helper()
|
||||
full := filepath.Join(recordingsDir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir recording directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write recording %s: %v", name, err)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtplpcm"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpmpeg4audio"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpsimpleaudio"
|
||||
"github.com/bluenviron/mediacommon/pkg/bits"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h265"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
|
||||
@@ -159,13 +160,10 @@ type Golibrtsp struct {
|
||||
}
|
||||
|
||||
// streamHealth instruments the RTSP read path. gortsplib delivers every RTP
|
||||
// packet on a single read goroutine; queue.WritePacket() is synchronous, so if
|
||||
// a downstream consumer (recording, muxing, WebRTC) is slow or the process is
|
||||
// CPU-starved, WritePacket() blocks, the TCP socket is not drained, and the
|
||||
// camera advances RTP sequence numbers -> "RTP packets lost". This type makes
|
||||
// the two failure modes distinguishable:
|
||||
// - large writeMax / writeAvg => downstream back-pressure (our side).
|
||||
// - large gapMax with fast writes => upstream network / camera stall.
|
||||
// packet on a single read goroutine, so prolonged queue lock contention or
|
||||
// process starvation can delay socket reads. Queue consumers process packets
|
||||
// after releasing the lock; a slow recorder therefore falls behind its cursor
|
||||
// instead of directly blocking WritePacket.
|
||||
type streamHealth struct {
|
||||
mu sync.Mutex
|
||||
windowStart time.Time
|
||||
@@ -178,6 +176,152 @@ type streamHealth struct {
|
||||
decodeErrs int64
|
||||
}
|
||||
|
||||
type videoReferenceState struct {
|
||||
haveSequence bool
|
||||
lastSequence uint16
|
||||
valid bool
|
||||
droppedAccessUnits uint64
|
||||
}
|
||||
|
||||
type h264FrameNumTracker struct {
|
||||
sps *h264.SPS
|
||||
haveFrameNum bool
|
||||
lastFrameNum uint32
|
||||
}
|
||||
|
||||
const maxH264SliceHeaderBytes = 64
|
||||
|
||||
func (t *h264FrameNumTracker) setSPS(sps *h264.SPS) {
|
||||
if sps == nil {
|
||||
return
|
||||
}
|
||||
if t.sps == nil ||
|
||||
t.sps.Log2MaxFrameNumMinus4 != sps.Log2MaxFrameNumMinus4 ||
|
||||
t.sps.SeparateColourPlaneFlag != sps.SeparateColourPlaneFlag ||
|
||||
t.sps.FrameMbsOnlyFlag != sps.FrameMbsOnlyFlag {
|
||||
t.haveFrameNum = false
|
||||
}
|
||||
copy := *sps
|
||||
t.sps = ©
|
||||
}
|
||||
|
||||
func (t *h264FrameNumTracker) observe(au [][]byte) (uint32, uint32, bool, bool) {
|
||||
if t.sps == nil || !t.sps.FrameMbsOnlyFlag {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
|
||||
for _, nalu := range au {
|
||||
if len(nalu) == 0 {
|
||||
continue
|
||||
}
|
||||
naluType := h264.NALUType(nalu[0] & 0x1F)
|
||||
if naluType != h264.NALUTypeIDR && naluType != h264.NALUTypeNonIDR {
|
||||
continue
|
||||
}
|
||||
|
||||
frameNum, err := h264SliceFrameNum(nalu, t.sps)
|
||||
if err != nil {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
if naluType == h264.NALUTypeIDR {
|
||||
t.haveFrameNum = true
|
||||
t.lastFrameNum = frameNum
|
||||
return frameNum, frameNum, false, true
|
||||
}
|
||||
|
||||
// Non-reference pictures do not advance the reference-picture frame_num
|
||||
// sequence and therefore cannot prove that a reference picture is missing.
|
||||
if (nalu[0]>>5)&0x03 == 0 {
|
||||
return frameNum, frameNum, false, true
|
||||
}
|
||||
if !t.haveFrameNum {
|
||||
t.haveFrameNum = true
|
||||
t.lastFrameNum = frameNum
|
||||
return frameNum, frameNum, false, true
|
||||
}
|
||||
|
||||
frameNumBits := t.sps.Log2MaxFrameNumMinus4 + 4
|
||||
if frameNumBits > 16 {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
maxFrameNum := uint32(1) << frameNumBits
|
||||
expected := (t.lastFrameNum + 1) & (maxFrameNum - 1)
|
||||
// When the SPS permits frame_num gaps, the decoder is required to
|
||||
// synthesize non-existing pictures and the discontinuity is not evidence
|
||||
// of transport loss. RTP/depacketizer errors still invalidate references.
|
||||
gap := frameNum != expected && !t.sps.GapsInFrameNumValueAllowedFlag
|
||||
t.lastFrameNum = frameNum
|
||||
return expected, frameNum, gap, true
|
||||
}
|
||||
|
||||
return 0, 0, false, false
|
||||
}
|
||||
|
||||
func h264SliceFrameNum(nalu []byte, sps *h264.SPS) (uint32, error) {
|
||||
if len(nalu) < 2 || sps == nil {
|
||||
return 0, errors.New("H264 slice or SPS is missing")
|
||||
}
|
||||
frameNumBits := sps.Log2MaxFrameNumMinus4 + 4
|
||||
if frameNumBits > 16 {
|
||||
return 0, fmt.Errorf("invalid H264 frame_num width: %d", frameNumBits)
|
||||
}
|
||||
header := nalu[1:]
|
||||
if len(header) > maxH264SliceHeaderBytes {
|
||||
header = header[:maxH264SliceHeaderBytes]
|
||||
}
|
||||
header = h264.EmulationPreventionRemove(header)
|
||||
pos := 0
|
||||
|
||||
for range 3 {
|
||||
if _, err := bits.ReadGolombUnsigned(header, &pos); err != nil {
|
||||
return 0, fmt.Errorf("read H264 slice header: %w", err)
|
||||
}
|
||||
}
|
||||
if sps.SeparateColourPlaneFlag {
|
||||
if _, err := bits.ReadBits(header, &pos, 2); err != nil {
|
||||
return 0, fmt.Errorf("read H264 colour plane: %w", err)
|
||||
}
|
||||
}
|
||||
frameNum, err := bits.ReadBits(header, &pos, int(frameNumBits))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read H264 frame_num: %w", err)
|
||||
}
|
||||
return uint32(frameNum), nil
|
||||
}
|
||||
|
||||
func (s *videoReferenceState) observeSequence(sequence uint16) (uint16, bool) {
|
||||
expected := sequence
|
||||
gap := false
|
||||
if s.haveSequence {
|
||||
expected = s.lastSequence + 1
|
||||
gap = sequence != expected
|
||||
if gap {
|
||||
s.valid = false
|
||||
}
|
||||
}
|
||||
s.haveSequence = true
|
||||
s.lastSequence = sequence
|
||||
return expected, gap
|
||||
}
|
||||
|
||||
func (s *videoReferenceState) invalidate() {
|
||||
s.valid = false
|
||||
}
|
||||
|
||||
func (s *videoReferenceState) accept(randomAccess bool) (bool, uint64) {
|
||||
if randomAccess {
|
||||
dropped := s.droppedAccessUnits
|
||||
s.droppedAccessUnits = 0
|
||||
s.valid = true
|
||||
return true, dropped
|
||||
}
|
||||
if !s.valid {
|
||||
s.droppedAccessUnits++
|
||||
return false, 0
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
const (
|
||||
streamHealthWindow = 10 * time.Second
|
||||
streamHealthWriteWarn = 150 * time.Millisecond
|
||||
@@ -190,9 +334,9 @@ func newStreamHealth() *streamHealth {
|
||||
}
|
||||
|
||||
// observePacket records one processed video frame: the wall-clock gap since the
|
||||
// previous frame (arrival cadence) and how long WritePacket() blocked
|
||||
// (back-pressure). It emits an immediate warning when either side stalls and a
|
||||
// periodic summary every streamHealthWindow.
|
||||
// previous frame and how long WritePacket spent acquiring and updating the
|
||||
// queue. It emits an immediate warning when either side stalls and a periodic
|
||||
// summary every streamHealthWindow.
|
||||
func (h *streamHealth) observePacket(streamType string, writeDur time.Duration) {
|
||||
if h == nil {
|
||||
return
|
||||
@@ -264,9 +408,9 @@ func (h *streamHealth) observePacket(streamType string, writeDur time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// observeLost is invoked by gortsplib when RTP sequence numbers skip. On a TCP
|
||||
// transport this means the sender (camera) dropped packets because we were not
|
||||
// reading fast enough, not loss on the wire.
|
||||
// observeLost is invoked by gortsplib when RTP sequence numbers skip. With the
|
||||
// configured TCP transport, the sender or an upstream RTSP component omitted
|
||||
// those packets; sender-side pressure is one possible cause.
|
||||
func (h *streamHealth) observeLost(streamType string, lost uint64) {
|
||||
if h == nil {
|
||||
return
|
||||
@@ -887,6 +1031,14 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// called when a video RTP packet arrives for H264
|
||||
var filteredAU [][]byte
|
||||
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
|
||||
referenceState := &videoReferenceState{}
|
||||
frameNumTracker := &h264FrameNumTracker{}
|
||||
if len(g.VideoH264Forma.SPS) > 0 {
|
||||
var sps h264.SPS
|
||||
if sps.Unmarshal(g.VideoH264Forma.SPS) == nil {
|
||||
frameNumTracker.setSPS(&sps)
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H264 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
@@ -902,6 +1054,16 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
default:
|
||||
}
|
||||
|
||||
if expected, gap := referenceState.observeSequence(rtppkt.SequenceNumber); gap {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
"event": "video_rtp_sequence_gap",
|
||||
"expected_sequence_number": expected,
|
||||
"received_sequence_number": rtppkt.SequenceNumber,
|
||||
"stream": streamType,
|
||||
}).Warn("RTP sequence gap invalidated video reference chain")
|
||||
}
|
||||
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
@@ -917,6 +1079,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// this is a keyframe.
|
||||
au, errDecode := g.VideoH264Decoder.Decode(rtppkt)
|
||||
if errDecode != nil {
|
||||
if errDecode != rtph264.ErrMorePacketsNeeded {
|
||||
referenceState.invalidate()
|
||||
}
|
||||
if errDecode != rtph264.ErrNonStartingPacketAndNoPrevious && errDecode != rtph264.ErrMorePacketsNeeded {
|
||||
log.Error("capture.golibrtsp.Start(): " + errDecode.Error())
|
||||
}
|
||||
@@ -961,6 +1126,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if errSPS == nil {
|
||||
// Debug SPS information
|
||||
g.debugSPSInfo(&sps, streamType)
|
||||
frameNumTracker.setSPS(&sps)
|
||||
|
||||
// Get width
|
||||
g.Streams[g.VideoH264Index].Width = sps.Width()
|
||||
@@ -1022,6 +1188,39 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
if expected, received, gap, ok := frameNumTracker.observe(au); ok && gap {
|
||||
referenceState.invalidate()
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
"event": "h264_frame_num_gap",
|
||||
"expected_frame_num": expected,
|
||||
"received_frame_num": received,
|
||||
"stream": streamType,
|
||||
}).Warn("H264 frame_num gap invalidated video reference chain")
|
||||
}
|
||||
|
||||
accepted, recoveredDropped := referenceState.accept(idrPresent)
|
||||
if !accepted {
|
||||
if referenceState.droppedAccessUnits == 1 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H264",
|
||||
"component": "capture",
|
||||
"event": "dependent_access_units_dropped",
|
||||
"stream": streamType,
|
||||
}).Warn("Dropping dependent video frames until the next IDR")
|
||||
}
|
||||
return
|
||||
}
|
||||
if recoveredDropped > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H264",
|
||||
"component": "capture",
|
||||
"dropped_access_units": recoveredDropped,
|
||||
"event": "video_reference_chain_recovered",
|
||||
"stream": streamType,
|
||||
}).Info("Video reference chain recovered at IDR")
|
||||
}
|
||||
|
||||
if idrPresent {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
@@ -1165,6 +1364,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
// called when a video RTP packet arrives for H265
|
||||
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
|
||||
referenceState := &videoReferenceState{}
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H265 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
@@ -1180,6 +1380,16 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
default:
|
||||
}
|
||||
|
||||
if expected, gap := referenceState.observeSequence(rtppkt.SequenceNumber); gap {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
"event": "video_rtp_sequence_gap",
|
||||
"expected_sequence_number": expected,
|
||||
"received_sequence_number": rtppkt.SequenceNumber,
|
||||
"stream": streamType,
|
||||
}).Warn("RTP sequence gap invalidated video reference chain")
|
||||
}
|
||||
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
@@ -1195,6 +1405,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// this is a keyframe.
|
||||
au, errDecode := g.VideoH265Decoder.Decode(rtppkt)
|
||||
if errDecode != nil {
|
||||
if errDecode != rtph265.ErrMorePacketsNeeded {
|
||||
referenceState.invalidate()
|
||||
}
|
||||
if errDecode != rtph265.ErrNonStartingPacketAndNoPrevious && errDecode != rtph265.ErrMorePacketsNeeded {
|
||||
log.Error("capture.golibrtsp.Start(): " + errDecode.Error())
|
||||
}
|
||||
@@ -1244,6 +1457,28 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
accepted, recoveredDropped := referenceState.accept(isRandomAccess)
|
||||
if !accepted {
|
||||
if referenceState.droppedAccessUnits == 1 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H265",
|
||||
"component": "capture",
|
||||
"event": "dependent_access_units_dropped",
|
||||
"stream": streamType,
|
||||
}).Warn("Dropping dependent video frames until the next random-access frame")
|
||||
}
|
||||
return
|
||||
}
|
||||
if recoveredDropped > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H265",
|
||||
"component": "capture",
|
||||
"dropped_access_units": recoveredDropped,
|
||||
"event": "video_reference_chain_recovered",
|
||||
"stream": streamType,
|
||||
}).Info("Video reference chain recovered at random-access frame")
|
||||
}
|
||||
|
||||
// add VPS, SPS and PPS before random access access unit
|
||||
if isRandomAccess {
|
||||
au = append([][]byte{
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
)
|
||||
|
||||
func TestPreRecordingGOPCount(t *testing.T) {
|
||||
@@ -134,3 +136,114 @@ func TestRTSPSTLSConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVideoReferenceStateDropsDependentFramesUntilRandomAccess(t *testing.T) {
|
||||
state := &videoReferenceState{}
|
||||
|
||||
accepted, recovered := state.accept(false)
|
||||
if accepted || recovered != 0 || state.droppedAccessUnits != 1 {
|
||||
t.Fatalf("initial dependent frame = (accepted=%t, recovered=%d, dropped=%d), want (false, 0, 1)",
|
||||
accepted, recovered, state.droppedAccessUnits)
|
||||
}
|
||||
|
||||
accepted, recovered = state.accept(true)
|
||||
if !accepted || recovered != 1 || !state.valid {
|
||||
t.Fatalf("random-access recovery = (accepted=%t, recovered=%d, valid=%t), want (true, 1, true)",
|
||||
accepted, recovered, state.valid)
|
||||
}
|
||||
|
||||
if expected, gap := state.observeSequence(100); gap || expected != 100 {
|
||||
t.Fatalf("first sequence observation = (expected=%d, gap=%t), want (100, false)", expected, gap)
|
||||
}
|
||||
if expected, gap := state.observeSequence(102); !gap || expected != 101 || state.valid {
|
||||
t.Fatalf("sequence gap = (expected=%d, gap=%t, valid=%t), want (101, true, false)",
|
||||
expected, gap, state.valid)
|
||||
}
|
||||
|
||||
accepted, recovered = state.accept(false)
|
||||
if accepted || recovered != 0 || state.droppedAccessUnits != 1 {
|
||||
t.Fatalf("post-loss dependent frame = (accepted=%t, recovered=%d, dropped=%d), want (false, 0, 1)",
|
||||
accepted, recovered, state.droppedAccessUnits)
|
||||
}
|
||||
|
||||
accepted, recovered = state.accept(true)
|
||||
if !accepted || recovered != 1 || !state.valid {
|
||||
t.Fatalf("post-loss recovery = (accepted=%t, recovered=%d, valid=%t), want (true, 1, true)",
|
||||
accepted, recovered, state.valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoReferenceStateAcceptsSequenceWraparound(t *testing.T) {
|
||||
state := &videoReferenceState{valid: true}
|
||||
|
||||
if _, gap := state.observeSequence(^uint16(0)); gap {
|
||||
t.Fatal("first sequence observation reported a gap")
|
||||
}
|
||||
if expected, gap := state.observeSequence(0); gap || expected != 0 || !state.valid {
|
||||
t.Fatalf("sequence wraparound = (expected=%d, gap=%t, valid=%t), want (0, false, true)",
|
||||
expected, gap, state.valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH264FrameNumTrackerDetectsMissingIDR(t *testing.T) {
|
||||
tracker := &h264FrameNumTracker{}
|
||||
tracker.setSPS(&h264.SPS{
|
||||
Log2MaxFrameNumMinus4: 1,
|
||||
FrameMbsOnlyFlag: true,
|
||||
})
|
||||
|
||||
if _, _, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)}); !ok || gap {
|
||||
t.Fatalf("initial IDR observation = (ok=%t, gap=%t), want (true, false)", ok, gap)
|
||||
}
|
||||
for frameNum := uint32(1); frameNum <= 24; frameNum++ {
|
||||
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, frameNum)}); !ok || gap {
|
||||
t.Fatalf("frame_num %d observation = (expected=%d, received=%d, ok=%t, gap=%t), want no gap",
|
||||
frameNum, expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)})
|
||||
if !ok || !gap || expected != 25 || received != 1 {
|
||||
t.Fatalf("missing IDR transition = (expected=%d, received=%d, ok=%t, gap=%t), want (25, 1, true, true)",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH264FrameNumTrackerResetsAtIDR(t *testing.T) {
|
||||
tracker := &h264FrameNumTracker{}
|
||||
tracker.setSPS(&h264.SPS{
|
||||
Log2MaxFrameNumMinus4: 1,
|
||||
FrameMbsOnlyFlag: true,
|
||||
})
|
||||
|
||||
tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)})
|
||||
tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)})
|
||||
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)}); !ok || gap {
|
||||
t.Fatalf("IDR reset = (expected=%d, received=%d, ok=%t, gap=%t), want no gap",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)}); !ok || gap || expected != 1 || received != 1 {
|
||||
t.Fatalf("post-IDR frame = (expected=%d, received=%d, ok=%t, gap=%t), want (1, 1, true, false)",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH264FrameNumTrackerHonorsPermittedGaps(t *testing.T) {
|
||||
tracker := &h264FrameNumTracker{}
|
||||
tracker.setSPS(&h264.SPS{
|
||||
Log2MaxFrameNumMinus4: 1,
|
||||
FrameMbsOnlyFlag: true,
|
||||
GapsInFrameNumValueAllowedFlag: true,
|
||||
})
|
||||
|
||||
tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)})
|
||||
expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 2)})
|
||||
if !ok || gap || expected != 1 || received != 2 {
|
||||
t.Fatalf("permitted frame_num gap = (expected=%d, received=%d, ok=%t, gap=%t), want (1, 2, true, false)",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
func testH264Slice(naluType h264.NALUType, frameNum uint32) []byte {
|
||||
return []byte{0x60 | byte(naluType), 0xE0 | byte(frameNum&0x1F)}
|
||||
}
|
||||
|
||||
+35
-161
@@ -53,13 +53,16 @@ func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration
|
||||
}
|
||||
}
|
||||
|
||||
func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *video.MP4, encrypted bool) models.RecordingUploadMetadata {
|
||||
func recordingUploadMetadata(name string, config models.Config, timestamp int64, mp4Video *video.MP4, encrypted bool, coordinates string, changes int) models.RecordingUploadMetadata {
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: filepath.Base(name),
|
||||
DeviceKey: deviceKey,
|
||||
Timestamp: timestamp,
|
||||
Duration: mp4Video.VideoTotalDuration,
|
||||
Encrypted: encrypted,
|
||||
FileName: filepath.Base(name),
|
||||
DeviceKey: config.Key,
|
||||
Timestamp: timestamp,
|
||||
Duration: mp4Video.VideoTotalDuration,
|
||||
Encrypted: encrypted,
|
||||
DeviceName: config.Name,
|
||||
RegionCoordinates: coordinates,
|
||||
NumberOfChanges: strconv.Itoa(changes),
|
||||
}
|
||||
value := mp4Video.AverageFPS()
|
||||
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
|
||||
@@ -190,7 +193,7 @@ func CleanupRecordingDirectory(configDirectory string, configuration *models.Con
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
|
||||
if err := utils.RemoveRecording(recordingsDirectory, name); err != nil {
|
||||
log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
@@ -275,7 +278,7 @@ func defaultReserveMB(totalMB int64) int64 {
|
||||
// overall with pending=true, signalling the caller that it is about to drop an
|
||||
// un-uploaded recording as a last resort.
|
||||
func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (string, bool, error) {
|
||||
entries, err := os.ReadDir(recordingsDirectory)
|
||||
entries, err := utils.ReadRecordingDirectory(recordingsDirectory)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
@@ -284,14 +287,7 @@ func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (string,
|
||||
var oldestSafeTime, oldestAnyTime time.Time
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
modTime := info.ModTime()
|
||||
modTime := entry.ModTime()
|
||||
|
||||
if oldestAnyName == "" || modTime.Before(oldestAnyTime) {
|
||||
oldestAnyName = entry.Name()
|
||||
@@ -449,39 +445,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Cleanup muxer
|
||||
start = false
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
duration := mp4Video.VideoTotalDuration
|
||||
// Update the name with the duration in milliseconds.
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
|
||||
config.Name + "_" +
|
||||
"0-0-0-0" + "_" + // region coordinates, we
|
||||
"-1" + "_" + // token
|
||||
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
oldName := name
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
|
||||
|
||||
// Rename the file to the new name.
|
||||
err := os.Rename(
|
||||
configDirectory+"/data/recordings/"+oldName,
|
||||
configDirectory+"/data/recordings/"+s+".mp4")
|
||||
|
||||
if err != nil {
|
||||
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
// Check if we need to encrypt the recording.
|
||||
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
|
||||
@@ -506,7 +469,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video, encrypted))
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, startRecording, mp4Video, encrypted, "0-0-0-0", -1))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
@@ -531,29 +494,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
|
||||
start = true
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
startRecording = pkt.CurrentTime
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" + // start timestamp in seconds
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" + // length of milliseconds
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" + // milliseconds
|
||||
config.Name + "_" + // device name
|
||||
"0-0-0-0" + "_" + // region coordinates, we will not use this for continuous recording
|
||||
"0" + "_" + // token
|
||||
"0" + "_" //+ // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
name, err = utils.RecordingFileName(startRecording, config.Key)
|
||||
if err == nil {
|
||||
fullName, err = utils.CreateRecordingPath(configDirectory+"/data/recordings", name, time.UnixMilli(startRecording))
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).Error("capture: could not create continuous recording path")
|
||||
start = false
|
||||
pkt = nextPkt
|
||||
continue
|
||||
}
|
||||
|
||||
// Running...
|
||||
log.Info("capture.main.HandleRecordStream(continuous): recording started")
|
||||
@@ -609,39 +560,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Cleanup muxer
|
||||
start = false
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
duration := mp4Video.VideoTotalDuration
|
||||
// Update the name with the duration in milliseconds.
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
|
||||
config.Name + "_" +
|
||||
"0-0-0-0" + "_" + // region coordinates, we
|
||||
"-1" + "_" + // token
|
||||
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
oldName := name
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
|
||||
|
||||
// Rename the file to the new name.
|
||||
err := os.Rename(
|
||||
configDirectory+"/data/recordings/"+oldName,
|
||||
configDirectory+"/data/recordings/"+s+".mp4")
|
||||
|
||||
if err != nil {
|
||||
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
// Check if we need to encrypt the recording.
|
||||
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
|
||||
@@ -666,7 +584,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video, encrypted))
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, startRecording, mp4Video, encrypted, "0-0-0-0", -1))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
@@ -721,17 +639,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
continue
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
displayTimeSeconds := displayTime / 1000 // convert to seconds
|
||||
displayTimeMilliseconds := displayTime % 1000 // convert to milliseconds
|
||||
displayTimeSeconds := displayTime / 1000
|
||||
motionRectangleString := "0-0-0-0"
|
||||
if motion.Rectangle.X != 0 || motion.Rectangle.Y != 0 ||
|
||||
motion.Rectangle.Width != 0 || motion.Rectangle.Height != 0 {
|
||||
@@ -742,17 +650,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Get the number of changes from the motion detection.
|
||||
numberOfChanges := motion.NumberOfChanges
|
||||
|
||||
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" + // start timestamp in seconds
|
||||
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" + // length of milliseconds
|
||||
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" + // milliseconds
|
||||
config.Name + "_" + // device name
|
||||
motionRectangleString + "_" + // region coordinates, we will not use this for continuous recording
|
||||
strconv.Itoa(numberOfChanges) + "_" + // number of changes
|
||||
"0" // + "_" + // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
name := s + ".mp4"
|
||||
fullName := configDirectory + "/data/recordings/" + name
|
||||
name, err := utils.RecordingFileName(displayTime, config.Key)
|
||||
var fullName string
|
||||
if err == nil {
|
||||
fullName, err = utils.CreateRecordingPath(configDirectory+"/data/recordings", name, time.UnixMilli(displayTime))
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).Error("capture: could not create motion recording path")
|
||||
continue
|
||||
}
|
||||
|
||||
// Running...
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
|
||||
@@ -880,38 +786,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
communication.TrySendMotion(models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000})
|
||||
}
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
duration := mp4Video.VideoTotalDuration
|
||||
|
||||
// Update the name with the duration in milliseconds.
|
||||
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" +
|
||||
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" +
|
||||
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" +
|
||||
config.Name + "_" +
|
||||
motionRectangleString + "_" +
|
||||
strconv.Itoa(numberOfChanges) + "_" + // number of changes
|
||||
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
oldName := name
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
|
||||
|
||||
// Rename the file to the new name.
|
||||
err := os.Rename(
|
||||
configDirectory+"/data/recordings/"+oldName,
|
||||
configDirectory+"/data/recordings/"+s+".mp4")
|
||||
|
||||
if err != nil {
|
||||
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
// Check if we need to encrypt the recording.
|
||||
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
|
||||
@@ -936,7 +810,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, displayTime, mp4Video, encrypted))
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, displayTime, mp4Video, encrypted, motionRectangleString, numberOfChanges))
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
|
||||
@@ -42,10 +42,11 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
|
||||
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video, true)
|
||||
config := models.Config{Key: "device-key", Name: "camera-name"}
|
||||
metadata := recordingUploadMetadata("1785934709414-device-key.mp4", config, 1785934709414, mp4Video, true, "1-2-3-4", 57)
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "1785934709414-device-key.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
@@ -54,7 +55,8 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
t.Fatalf("decode upload marker: %v", err)
|
||||
}
|
||||
expectedFPS := mp4Video.AverageFPS()
|
||||
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 || !stored.Encrypted {
|
||||
if stored.FileName != "1785934709414-device-key.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 || !stored.Encrypted ||
|
||||
stored.DeviceName != "camera-name" || stored.RegionCoordinates != "1-2-3-4" || stored.NumberOfChanges != "57" {
|
||||
t.Fatalf("upload marker = %+v", stored)
|
||||
}
|
||||
if stored.FPS == math.Floor(stored.FPS) {
|
||||
|
||||
@@ -125,7 +125,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
// Check if we need to remove the original recording
|
||||
// removeAfterUpload is set to false by default
|
||||
if config.RemoveAfterUpload != "false" {
|
||||
err := os.Remove(configDirectory + "/data/recordings/" + fileName)
|
||||
err := utils.RemoveRecording(configDirectory+"/data/recordings", fileName)
|
||||
if err != nil {
|
||||
log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -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 != "" {
|
||||
@@ -1454,13 +1459,19 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
|
||||
// @Summary Will verify the hub connectivity.
|
||||
// @Description Will verify the hub connectivity.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func VerifyHub(c *gin.Context) {
|
||||
func VerifyHub(c *gin.Context, configuredHubURI string) {
|
||||
|
||||
var config models.Config
|
||||
err := c.BindJSON(&config)
|
||||
|
||||
if err == nil {
|
||||
hubURI := config.HubURI
|
||||
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Data: "cloud.VerifyHub(): " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
publicKey := config.HubKey
|
||||
privateKey := config.HubPrivateKey
|
||||
|
||||
@@ -1473,9 +1484,9 @@ func VerifyHub(c *gin.Context) {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -1524,7 +1535,7 @@ func VerifyHub(c *gin.Context) {
|
||||
// @Summary Will verify the persistence.
|
||||
// @Description Will verify the persistence.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
func VerifyPersistence(c *gin.Context, configDirectory, configuredHubURI string) {
|
||||
|
||||
var config models.Config
|
||||
err := c.BindJSON(&config)
|
||||
@@ -1537,6 +1548,7 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
if config.HubURI == "" ||
|
||||
config.HubKey == "" ||
|
||||
config.HubPrivateKey == "" ||
|
||||
config.S3 == nil ||
|
||||
config.S3.Region == "" {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub not properly configured."
|
||||
log.Error(msg)
|
||||
@@ -1544,25 +1556,24 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
Data: msg,
|
||||
})
|
||||
} else {
|
||||
|
||||
// Open test-480p.mp4
|
||||
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
|
||||
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
|
||||
if err != nil {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): error reading test-480p.mp4: " + err.Error()
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): " + err.Error()
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Data: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", config.HubURI+"/storage/upload", file)
|
||||
req, err := http.NewRequest(http.MethodHead, hubURI+"/storage/upload", nil)
|
||||
if err != nil {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): error reading Kerberos Hub HEAD request, " + config.HubURI + "/storage: " + err.Error()
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): error creating Kerberos Hub upload authorization request: " + err.Error()
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
@@ -1580,9 +1591,9 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -1598,14 +1609,17 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
Data: msg,
|
||||
})
|
||||
} else {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): upload not allowed using the configured credentials"
|
||||
msg := hubPersistenceVerificationError(resp.StatusCode)
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: msg,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): Error creating Kerberos Hub request"
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub upload authorization request failed"
|
||||
if err != nil {
|
||||
msg += ": " + err.Error()
|
||||
}
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: msg,
|
||||
@@ -1774,6 +1788,35 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
}
|
||||
}
|
||||
|
||||
func configuredHubVerificationURI(requested, configured string) (string, error) {
|
||||
requested = strings.TrimRight(strings.TrimSpace(requested), "/")
|
||||
configured = strings.TrimRight(strings.TrimSpace(configured), "/")
|
||||
if configured == "" {
|
||||
return "", errors.New("save the Hub URI before verifying it")
|
||||
}
|
||||
if requested != configured {
|
||||
return "", errors.New("the Hub URI does not match the saved configuration; save it before verifying")
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
|
||||
func rejectHubVerificationRedirect(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
func hubPersistenceVerificationError(statusCode int) string {
|
||||
reason := "Kerberos Hub rejected upload authorization"
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
reason += ": the credentials or device/project assignment are invalid, or the storage limit was reached"
|
||||
case http.StatusUnauthorized:
|
||||
reason += ": the account has no active subscription or the subscription expired"
|
||||
case http.StatusForbidden:
|
||||
reason += ": the device is muted"
|
||||
}
|
||||
return "cloud.VerifyPersistence(kerberoshub): " + reason + " (HTTP " + strconv.Itoa(statusCode) + ")"
|
||||
}
|
||||
|
||||
// VerifySecondaryPersistence godoc
|
||||
// @Router /api/persistence/secondary/verify [post]
|
||||
// @ID verify-secondary-persistence
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
agentonvif "github.com/kerberos-io/agent/machinery/src/onvif"
|
||||
goonvif "github.com/kerberos-io/onvif"
|
||||
@@ -18,6 +21,202 @@ import (
|
||||
goonvifxsd "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func TestVerifyPersistenceKerberosHubUsesAuthorizationHead(t *testing.T) {
|
||||
var requestMethod string
|
||||
var requestBody []byte
|
||||
var requestHeaders http.Header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
requestHeaders = r.Header.Clone()
|
||||
requestBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := models.Config{
|
||||
Cloud: "kerberoshub",
|
||||
Key: "camera-1",
|
||||
Name: "front-door",
|
||||
HubURI: server.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
S3: &models.S3{Region: "eu"},
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyPersistence(ctx, t.TempDir(), server.URL)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if requestMethod != http.MethodHead {
|
||||
t.Fatalf("Hub request method = %q, want HEAD", requestMethod)
|
||||
}
|
||||
if len(requestBody) != 0 {
|
||||
t.Fatalf("Hub request body has %d bytes, want none", len(requestBody))
|
||||
}
|
||||
for header, want := range map[string]string{
|
||||
"X-Kerberos-Storage-Device": "camera-1",
|
||||
"X-Kerberos-Hub-PublicKey": "project-public",
|
||||
"X-Kerberos-Hub-PrivateKey": "project-private",
|
||||
"X-Kerberos-Hub-Region": "eu",
|
||||
"X-Kerberos-Storage-Capture": "IPCamera",
|
||||
} {
|
||||
if got := requestHeaders.Get(header); got != want {
|
||||
t.Errorf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPersistenceKerberosHubRejectsCrossHostRedirect(t *testing.T) {
|
||||
var redirectedHeaders http.Header
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
redirectedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
var originalHeaders http.Header
|
||||
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
originalHeaders = r.Header.Clone()
|
||||
http.Redirect(w, r, target.URL+"/storage/upload", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer hub.Close()
|
||||
|
||||
config := models.Config{
|
||||
Cloud: "kerberoshub",
|
||||
Key: "camera-1",
|
||||
Name: "front-door",
|
||||
HubURI: hub.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
S3: &models.S3{Region: "eu"},
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyPersistence(ctx, t.TempDir(), hub.URL)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
for _, header := range []string{"X-Kerberos-Hub-PublicKey", "X-Kerberos-Hub-PrivateKey"} {
|
||||
if originalHeaders.Get(header) == "" {
|
||||
t.Errorf("%s was not sent to the configured Hub", header)
|
||||
}
|
||||
if redirectedHeaders != nil {
|
||||
t.Errorf("redirect target received a request with %s=%q", header, redirectedHeaders.Get(header))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPersistenceKerberosHubRejectsUnsavedURI(t *testing.T) {
|
||||
requestCount := 0
|
||||
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer untrusted.Close()
|
||||
|
||||
config := models.Config{
|
||||
Cloud: "kerberoshub",
|
||||
Key: "camera-1",
|
||||
Name: "front-door",
|
||||
HubURI: untrusted.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
S3: &models.S3{Region: "eu"},
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyPersistence(ctx, t.TempDir(), "https://saved-hub.example.com")
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHubRejectsUnsavedURI(t *testing.T) {
|
||||
requestCount := 0
|
||||
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer untrusted.Close()
|
||||
|
||||
config := models.Config{
|
||||
HubURI: untrusted.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/hub/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyHub(ctx, "https://saved-hub.example.com")
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubPersistenceVerificationErrorExplainsStatus(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{status: http.StatusBadRequest, want: "credentials or device/project assignment"},
|
||||
{status: http.StatusUnauthorized, want: "no active subscription"},
|
||||
{status: http.StatusForbidden, want: "device is muted"},
|
||||
{status: http.StatusBadGateway, want: "HTTP 502"},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
if got := hubPersistenceVerificationError(test.status); !strings.Contains(got, test.want) {
|
||||
t.Fatalf("hubPersistenceVerificationError(%d) = %q, want it to contain %q", test.status, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFailureLogOmitsHubResponseBody(t *testing.T) {
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
@@ -97,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
|
||||
}
|
||||
@@ -198,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
|
||||
}
|
||||
@@ -278,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")
|
||||
@@ -419,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
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ package cloud
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/users"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -37,14 +37,12 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
|
||||
// Upload to Dropbox
|
||||
log.Info("UploadDropbox: Uploading to Dropbox")
|
||||
log.Info("UploadDropbox: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
dConfig := dropbox.Config{
|
||||
Token: token,
|
||||
LogLevel: dropbox.LogInfo, // if needed, set the desired logging level. Default is off
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -78,10 +79,8 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
}).Info("Resumable Hub upload unavailable; using legacy upload")
|
||||
}
|
||||
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
// Check if we still have the file otherwise we abort the request.
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -35,10 +36,23 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
// This can happen when the file was already removed (e.g. cleanup, or an
|
||||
// earlier successful upload). Skip it so the watcher drops the marker
|
||||
// instead of retrying indefinitely.
|
||||
info, err := os.Stat("data/recordings/" + fileName)
|
||||
filePath, err := utils.ResolveRecordingPath("data/recordings", fileName)
|
||||
if err != nil {
|
||||
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
if os.IsNotExist(err) {
|
||||
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
log.WithError(err).Error("UploadKerberosVault: could not resolve recording path")
|
||||
return false, true, err
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
log.WithError(err).Error("UploadKerberosVault: could not stat recording")
|
||||
return false, true, err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
log.Warn("UploadKerberosVault: skipping " + fileName + ", recording is empty")
|
||||
@@ -182,9 +196,7 @@ func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, la
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.Open(fullname)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,12 +11,16 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
|
||||
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
|
||||
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
|
||||
const recordingEncryptedHeader = "X-Kerberos-Storage-Encrypted"
|
||||
const recordingDeviceNameHeader = "X-Kerberos-Storage-DeviceName"
|
||||
const recordingRegionHeader = "X-Kerberos-Storage-RegionCoordinates"
|
||||
const recordingChangesHeader = "X-Kerberos-Storage-NumberOfChanges"
|
||||
|
||||
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
|
||||
// when the recording was finalized. Historical empty markers intentionally
|
||||
@@ -61,6 +66,14 @@ func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
value, err := readRecordingUploadMetadataFile(fileName)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.WithError(err).Error("Failed to read recording upload metadata")
|
||||
}
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadataFile(fileName string) ([]byte, error) {
|
||||
markerNames := []string{
|
||||
models.RecordingUploadMetadataFileName(fileName),
|
||||
filepath.Base(fileName),
|
||||
@@ -68,10 +81,13 @@ func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
for _, markerName := range markerNames {
|
||||
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
|
||||
if err == nil {
|
||||
return value, true
|
||||
return value, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read recording upload metadata %s: %w", markerName, err)
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
@@ -79,6 +95,18 @@ func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
header.Set(recordingFPSHeader, fps)
|
||||
}
|
||||
if metadata, ok := queuedRecordingMetadata(fileName); ok {
|
||||
if metadata.DeviceKey != "" {
|
||||
header.Set("X-Kerberos-Storage-Device", metadata.DeviceKey)
|
||||
}
|
||||
if metadata.DeviceName != "" {
|
||||
header.Set(recordingDeviceNameHeader, metadata.DeviceName)
|
||||
}
|
||||
if metadata.RegionCoordinates != "" {
|
||||
header.Set(recordingRegionHeader, metadata.RegionCoordinates)
|
||||
}
|
||||
if metadata.NumberOfChanges != "" {
|
||||
header.Set(recordingChangesHeader, metadata.NumberOfChanges)
|
||||
}
|
||||
if metadata.Duration > 0 {
|
||||
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
|
||||
}
|
||||
|
||||
+155
-36
@@ -2,14 +2,20 @@ package cloud
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
"github.com/minio/minio-go/v6"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -18,15 +24,6 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
if config.S3 == nil {
|
||||
errorMessage := "UploadS3: Uploading Failed, as no settings found"
|
||||
log.Error(errorMessage)
|
||||
@@ -71,24 +68,14 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
s3Client.SetCustomTransport(transport)
|
||||
}
|
||||
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 1 {
|
||||
errorMessage := "UploadS3: " + fileName + " is not a valid name."
|
||||
log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
objectName, userMetadata, err := s3RecordingUploadDetails(config, fileName, aws_access_key_id)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("UploadS3: invalid recording upload metadata")
|
||||
return false, true, err
|
||||
}
|
||||
|
||||
deviceKey := config.Key
|
||||
startRecording, _ := strconv.ParseInt(fileParts[0], 10, 64)
|
||||
devicename := fileParts[2]
|
||||
coordinates := fileParts[3]
|
||||
//numberOfChanges := fileParts[4]
|
||||
token, _ := strconv.Atoi(fileParts[5])
|
||||
|
||||
log.Info("UploadS3: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
@@ -107,23 +94,13 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
}
|
||||
|
||||
n, err := s3Client.PutObject(config.S3.Bucket,
|
||||
config.S3.Username+"/"+fileName,
|
||||
objectName,
|
||||
file,
|
||||
fileInfo.Size(),
|
||||
minio.PutObjectOptions{
|
||||
ContentType: "video/mp4",
|
||||
StorageClass: "ONEZONE_IA",
|
||||
UserMetadata: map[string]string{
|
||||
"event-timestamp": strconv.FormatInt(startRecording, 10),
|
||||
"event-microseconds": deviceKey,
|
||||
"event-instancename": devicename,
|
||||
"event-regioncoordinates": coordinates,
|
||||
"event-numberofchanges": deviceKey,
|
||||
"event-token": strconv.Itoa(token),
|
||||
"productid": deviceKey,
|
||||
"publickey": aws_access_key_id,
|
||||
"uploadtime": "now",
|
||||
},
|
||||
UserMetadata: userMetadata,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -135,3 +112,145 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
return true, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func s3RecordingObjectName(username, deviceKey string, startRecording int64) string {
|
||||
return username + "/" + time.UnixMilli(startRecording).UTC().Format("2006/01/02") +
|
||||
"/" + strconv.FormatInt(startRecording, 10) + "-" + deviceKey + ".mp4"
|
||||
}
|
||||
|
||||
func s3RecordingUploadDetails(config models.Config, fileName, publicKey string) (string, map[string]string, error) {
|
||||
value, err := readRecordingUploadMetadataFile(fileName)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
deviceKey := config.Key
|
||||
var startRecording int64
|
||||
userMetadata := map[string]string{
|
||||
"publickey": publicKey,
|
||||
"uploadtime": "now",
|
||||
}
|
||||
marker := strings.TrimSpace(string(value))
|
||||
_, fpsErr := strconv.ParseFloat(marker, 64)
|
||||
if err == nil && marker != "" && fpsErr != nil {
|
||||
var metadata models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(value, &metadata); err != nil {
|
||||
return "", nil, fmt.Errorf("decode recording upload metadata for %s: %w", fileName, err)
|
||||
}
|
||||
if metadata.FileName != "" && metadata.FileName != filepath.Base(fileName) {
|
||||
return "", nil, fmt.Errorf("recording upload metadata filename does not match %s", fileName)
|
||||
}
|
||||
if metadata.Timestamp <= 0 {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp in upload metadata for %s", fileName)
|
||||
}
|
||||
deviceKey = metadata.DeviceKey
|
||||
startRecording = metadata.Timestamp
|
||||
userMetadata["event-instancename"] = config.Name
|
||||
if metadata.DeviceName == "" || metadata.RegionCoordinates == "" || metadata.NumberOfChanges == "" {
|
||||
if description, ok := s3LegacyRecordingDescription(fileName); ok {
|
||||
for key, value := range description {
|
||||
userMetadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.DeviceName != "" {
|
||||
userMetadata["event-instancename"] = metadata.DeviceName
|
||||
}
|
||||
if metadata.RegionCoordinates != "" {
|
||||
userMetadata["event-regioncoordinates"] = metadata.RegionCoordinates
|
||||
}
|
||||
if metadata.NumberOfChanges != "" {
|
||||
userMetadata["event-numberofchanges"] = metadata.NumberOfChanges
|
||||
}
|
||||
userMetadata["event-microseconds"] = strconv.FormatInt(metadata.Timestamp%1000*1000, 10)
|
||||
userMetadata["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
userMetadata["encrypted"] = strconv.FormatBool(metadata.Encrypted)
|
||||
if metadata.FPS > 0 && metadata.FPS <= 240 {
|
||||
userMetadata["fps"] = strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
|
||||
}
|
||||
} else {
|
||||
// Empty and FPS-only markers predate structured recording metadata.
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) < 6 {
|
||||
return "", nil, fmt.Errorf("%s is not a valid legacy recording name", fileName)
|
||||
}
|
||||
startRecording, err = strconv.ParseInt(fileParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp in %s: %w", fileName, err)
|
||||
}
|
||||
if startRecording <= 0 || startRecording > math.MaxInt64/1000 {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp in %s", fileName)
|
||||
}
|
||||
startRecording *= 1000
|
||||
_, precision, found := strings.Cut(fileParts[1], "-")
|
||||
if found {
|
||||
fraction, err := strconv.ParseInt(precision, 10, 64)
|
||||
if err != nil || fraction < 0 || fraction >= 1000000 {
|
||||
return "", nil, fmt.Errorf("invalid recording precision in %s", fileName)
|
||||
}
|
||||
if len(precision) > 3 {
|
||||
fraction /= 1000
|
||||
}
|
||||
startRecording += fraction
|
||||
}
|
||||
token, _ := strconv.Atoi(fileParts[5])
|
||||
userMetadata["event-microseconds"] = deviceKey
|
||||
userMetadata["event-instancename"] = fileParts[2]
|
||||
userMetadata["event-regioncoordinates"] = fileParts[3]
|
||||
userMetadata["event-numberofchanges"] = deviceKey
|
||||
userMetadata["event-token"] = strconv.Itoa(token)
|
||||
}
|
||||
if deviceKey == "" || strings.ContainsAny(deviceKey, "/\\") {
|
||||
return "", nil, errors.New("missing or invalid recording device key")
|
||||
}
|
||||
if startRecording <= 0 {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp for %s", fileName)
|
||||
}
|
||||
userMetadata["event-timestamp"] = strconv.FormatInt(startRecording/1000, 10)
|
||||
userMetadata["productid"] = deviceKey
|
||||
return s3RecordingObjectName(config.S3.Username, deviceKey, startRecording), userMetadata, nil
|
||||
}
|
||||
|
||||
func s3LegacyRecordingDescription(fileName string) (map[string]string, bool) {
|
||||
base := filepath.Base(fileName)
|
||||
if filepath.Ext(base) != ".mp4" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSuffix(base, ".mp4"), "_")
|
||||
if len(parts) != 6 || parts[2] == "" {
|
||||
return nil, false
|
||||
}
|
||||
seconds, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || seconds <= 0 || seconds > math.MaxInt64/1000 {
|
||||
return nil, false
|
||||
}
|
||||
digits, fraction, found := strings.Cut(parts[1], "-")
|
||||
precision, err := strconv.Atoi(digits)
|
||||
if !found || err != nil || precision < 1 || precision > 6 || len(fraction) != precision {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := strconv.ParseUint(fraction, 10, 64); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
coordinates := strings.Split(parts[3], "-")
|
||||
if len(coordinates) != 4 {
|
||||
return nil, false
|
||||
}
|
||||
for _, coordinate := range coordinates {
|
||||
if _, err := strconv.ParseUint(coordinate, 10, 64); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
changes, err := strconv.ParseInt(parts[4], 10, 64)
|
||||
if err != nil || changes < -1 {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := strconv.ParseUint(parts[5], 10, 64); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]string{
|
||||
"event-instancename": parts[2],
|
||||
"event-regioncoordinates": parts[3],
|
||||
"event-numberofchanges": parts[4],
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestS3RecordingUploadDetailsUsesCheckpoint(t *testing.T) {
|
||||
for _, fileName := range []string{
|
||||
"1767355200123-checkpoint-key.mp4",
|
||||
"1564859471_6-474162_old-camera_0-0-0-0_1153_27.mp4",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
recorded := time.Date(2026, time.January, 2, 12, 0, 0, 123000000, time.UTC)
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: fileName,
|
||||
DeviceKey: "checkpoint-key",
|
||||
Timestamp: recorded.UnixMilli(),
|
||||
Duration: 20452,
|
||||
FPS: 29.97,
|
||||
Encrypted: true,
|
||||
DeviceName: "checkpoint-camera",
|
||||
RegionCoordinates: "1-2-3-4",
|
||||
NumberOfChanges: "57",
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, fileName, string(payload))
|
||||
config := models.Config{
|
||||
Key: "changed-config-key",
|
||||
Name: "camera-name",
|
||||
S3: &models.S3{Username: "user"},
|
||||
}
|
||||
objectName, userMetadata, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantObjectName := "user/2026/01/02/" + strconv.FormatInt(recorded.UnixMilli(), 10) + "-checkpoint-key.mp4"
|
||||
if objectName != wantObjectName {
|
||||
t.Fatalf("object name = %q, want %q", objectName, wantObjectName)
|
||||
}
|
||||
wantMetadata := map[string]string{
|
||||
"publickey": "public-key",
|
||||
"uploadtime": "now",
|
||||
"event-instancename": "checkpoint-camera",
|
||||
"event-regioncoordinates": "1-2-3-4",
|
||||
"event-numberofchanges": "57",
|
||||
"event-microseconds": "123000",
|
||||
"event-timestamp": strconv.FormatInt(recorded.Unix(), 10),
|
||||
"productid": "checkpoint-key",
|
||||
"duration": "20452",
|
||||
"fps": "29.97",
|
||||
"encrypted": "true",
|
||||
}
|
||||
if !reflect.DeepEqual(userMetadata, wantMetadata) {
|
||||
t.Fatalf("metadata = %#v, want %#v", userMetadata, wantMetadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsBackfillsLegacyCheckpointDescriptions(t *testing.T) {
|
||||
const legacyName = "1564859471_6-474162_recorded-camera_1-2-3-4_1153_27.mp4"
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
camera string
|
||||
region string
|
||||
changes string
|
||||
wantCamera string
|
||||
wantRegion string
|
||||
wantChanges string
|
||||
}{
|
||||
{"pre-upgrade checkpoint", legacyName, "", "", "", "recorded-camera", "1-2-3-4", "1153"},
|
||||
{"checkpoint camera", legacyName, "snapshot-camera", "", "", "snapshot-camera", "1-2-3-4", "1153"},
|
||||
{"checkpoint region", legacyName, "", "5-6-7-8", "", "recorded-camera", "5-6-7-8", "1153"},
|
||||
{"checkpoint zero changes", legacyName, "", "", "0", "recorded-camera", "1-2-3-4", "0"},
|
||||
{"dated reference", "2019/08/03/" + legacyName, "", "", "", "recorded-camera", "1-2-3-4", "1153"},
|
||||
{"continuous recording", "1564859471_3-474_recorded-camera_0-0-0-0_-1_27.mp4", "", "", "", "recorded-camera", "0-0-0-0", "-1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withRecording(t, filepath.Base(tt.fileName), []byte("recording"))
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: filepath.Base(tt.fileName),
|
||||
DeviceKey: "checkpoint-key",
|
||||
Timestamp: 1767355200123,
|
||||
Duration: 20452,
|
||||
FPS: 29.97,
|
||||
Encrypted: true,
|
||||
DeviceName: tt.camera,
|
||||
RegionCoordinates: tt.region,
|
||||
NumberOfChanges: tt.changes,
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, tt.fileName, string(payload))
|
||||
config := models.Config{Key: "current-key", Name: "current-camera", S3: &models.S3{Username: "user"}}
|
||||
objectName, got, err := s3RecordingUploadDetails(config, tt.fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "user/2026/01/02/1767355200123-checkpoint-key.mp4"; objectName != want {
|
||||
t.Fatalf("object name = %q, want %q", objectName, want)
|
||||
}
|
||||
want := map[string]string{
|
||||
"publickey": "public-key",
|
||||
"uploadtime": "now",
|
||||
"event-instancename": tt.wantCamera,
|
||||
"event-regioncoordinates": tt.wantRegion,
|
||||
"event-numberofchanges": tt.wantChanges,
|
||||
"event-microseconds": "123000",
|
||||
"event-timestamp": "1767355200",
|
||||
"productid": "checkpoint-key",
|
||||
"duration": "20452",
|
||||
"fps": "29.97",
|
||||
"encrypted": "true",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("metadata = %#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsDoesNotBackfillInvalidLegacyNames(t *testing.T) {
|
||||
for _, fileName := range []string{
|
||||
"1767355200123-checkpoint-key.mp4",
|
||||
"invalid_6-474162_camera_0-0-0-0_1153_27.mp4",
|
||||
"1564859471_6-invalid_camera_0-0-0-0_1153_27.mp4",
|
||||
"1564859471_3-474162_camera_0-0-0-0_1153_27.mp4",
|
||||
"1564859471_6-474162__0-0-0-0_1153_27.mp4",
|
||||
"1564859471_6-474162_camera_invalid_1153_27.mp4",
|
||||
"1564859471_6-474162_camera_0-0-0-0_invalid_27.mp4",
|
||||
"1564859471_6-474162_camera_0-0-0-0_1153_invalid.mp4",
|
||||
"1564859471_6-474162_camera_0-0-0-0_1153_27_extra.mp4",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: fileName, DeviceKey: "checkpoint-key", Timestamp: 1767355200123, Duration: 20452,
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, fileName, string(payload))
|
||||
config := models.Config{Key: "current-key", Name: "current-camera", S3: &models.S3{Username: "user"}}
|
||||
objectName, got, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if objectName != "user/2026/01/02/1767355200123-checkpoint-key.mp4" ||
|
||||
got["event-instancename"] != "current-camera" || got["event-regioncoordinates"] != "" ||
|
||||
got["event-numberofchanges"] != "" || got["event-timestamp"] != "1767355200" ||
|
||||
got["productid"] != "checkpoint-key" {
|
||||
t.Fatalf("unexpected fallback for invalid legacy name: %q, %#v", objectName, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsLegacyMarkers(t *testing.T) {
|
||||
for _, marker := range []string{"missing", "empty", "fps-only", "old-marker-name"} {
|
||||
t.Run(marker, func(t *testing.T) {
|
||||
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
switch marker {
|
||||
case "empty":
|
||||
withQueuedRecordingFPS(t, fileName, "")
|
||||
case "fps-only", "old-marker-name":
|
||||
withQueuedRecordingFPS(t, fileName, "29.97")
|
||||
if marker == "old-marker-name" {
|
||||
err := os.Rename(filepath.Join("data/cloud", models.RecordingUploadMetadataFileName(fileName)),
|
||||
filepath.Join("data/cloud", fileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
|
||||
objectName, metadata, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "user/2019/08/03/1564859471474-legacy-key.mp4"; objectName != want {
|
||||
t.Fatalf("object name = %q, want %q", objectName, want)
|
||||
}
|
||||
if metadata["productid"] != "legacy-key" || metadata["event-instancename"] != "camera" ||
|
||||
metadata["event-regioncoordinates"] != "0-0-0-0" || metadata["event-timestamp"] != "1564859471" {
|
||||
t.Fatalf("unexpected legacy metadata: %#v", metadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsRejectsInvalidCheckpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
marker string
|
||||
want string
|
||||
}{
|
||||
{"malformed JSON", `{"timestamp":`, "decode"},
|
||||
{"missing timestamp", `{"device_key":"camera"}`, "timestamp"},
|
||||
{"missing device key", `{"timestamp":1787015373000}`, "device key"},
|
||||
{"invalid device key", `{"timestamp":1787015373000,"device_key":"camera/key"}`, "device key"},
|
||||
{"mismatched filename", `{"timestamp":1787015373000,"device_key":"camera","filename":"other.mp4"}`, "filename"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, tt.marker)
|
||||
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
|
||||
_, _, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want error containing %q instead of legacy fallback", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsReportsCheckpointReadError(t *testing.T) {
|
||||
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
if err := os.MkdirAll(filepath.Join("data/cloud", models.RecordingUploadMetadataFileName(fileName)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
|
||||
_, _, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err == nil || !strings.Contains(err.Error(), "read recording upload metadata") {
|
||||
t.Fatalf("error = %v, want checkpoint read error instead of legacy fallback", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingObjectName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
recorded time.Time
|
||||
directory string
|
||||
}{
|
||||
{
|
||||
name: "zero padded month and day",
|
||||
recorded: time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC),
|
||||
directory: "2026/01/02",
|
||||
},
|
||||
{
|
||||
name: "UTC date across year boundary",
|
||||
recorded: time.Date(2026, time.January, 1, 1, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60)),
|
||||
directory: "2025/12/31",
|
||||
},
|
||||
{
|
||||
name: "leap day",
|
||||
recorded: time.Date(2024, time.February, 29, 23, 59, 59, 0, time.UTC),
|
||||
directory: "2024/02/29",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
const deviceKey = "camera-key-123"
|
||||
want := "user/" + tt.directory + "/" + strconv.FormatInt(tt.recorded.UnixMilli(), 10) + "-" + deviceKey + ".mp4"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if got := s3RecordingObjectName("user", deviceKey, tt.recorded.UnixMilli()); got != want {
|
||||
t.Fatalf("s3RecordingObjectName() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadS3RejectsInvalidRecordingNames(t *testing.T) {
|
||||
configuration := &models.Configuration{
|
||||
Config: models.Config{
|
||||
Key: "camera-key-123",
|
||||
S3: &models.S3{
|
||||
Publickey: "test-public-key",
|
||||
Secretkey: "test-secret-key",
|
||||
Region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, fileName := range []string{
|
||||
"recording.mp4",
|
||||
"1787015373_camera.mp4",
|
||||
"invalid_6-474162_camera_0-0-0-0_1153_27.mp4",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
uploaded, configured, err := UploadS3(configuration, fileName)
|
||||
if uploaded || !configured || err == nil {
|
||||
t.Fatalf("UploadS3() = (%v, %v, %v), want (false, true, error)", uploaded, configured, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadS3RejectsInvalidDeviceKeys(t *testing.T) {
|
||||
for _, deviceKey := range []string{"", "camera/key", `camera\key`} {
|
||||
t.Run(deviceKey, func(t *testing.T) {
|
||||
configuration := &models.Configuration{
|
||||
Config: models.Config{
|
||||
Key: deviceKey,
|
||||
S3: &models.S3{
|
||||
Publickey: "test-public-key",
|
||||
Secretkey: "test-secret-key",
|
||||
Region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
uploaded, configured, err := UploadS3(configuration, "1787015373_6-474162_camera_0-0-0-0_1153_27.mp4")
|
||||
if uploaded || !configured || err == nil || !strings.Contains(err.Error(), "device key") {
|
||||
t.Fatalf("UploadS3() = (%v, %v, %v), want (false, true, device key error)", uploaded, configured, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64)
|
||||
// creation request; it is empty on HEAD/PATCH/DELETE.
|
||||
type tusHeaderFunc func(h http.Header, fileName string)
|
||||
|
||||
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
|
||||
// runTusUpload performs a resumable (tus) upload of a flat or dated recording.
|
||||
// to baseURL, sending target-specific authentication/routing headers via
|
||||
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
|
||||
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
|
||||
@@ -120,9 +121,7 @@ type tusHeaderFunc func(h http.Header, fileName string)
|
||||
// should fall back to the legacy single-POST upload (older deployments).
|
||||
// - body: a short message for logging.
|
||||
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, ferr := os.Open(fullname)
|
||||
file, ferr := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
@@ -165,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.
|
||||
@@ -172,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
|
||||
@@ -188,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)
|
||||
@@ -244,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.
|
||||
@@ -290,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
|
||||
@@ -358,6 +366,12 @@ func addRecordingTusMetadata(values map[string]string, fileName string) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if metadata.DeviceKey != "" {
|
||||
values["device"] = metadata.DeviceKey
|
||||
}
|
||||
values["device_name"] = metadata.DeviceName
|
||||
values["region_coordinates"] = metadata.RegionCoordinates
|
||||
values["number_of_changes"] = metadata.NumberOfChanges
|
||||
if metadata.Duration > 0 {
|
||||
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
}
|
||||
@@ -390,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")
|
||||
@@ -500,6 +532,7 @@ func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceK
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
setQueuedRecordingMetadataHeaders(h, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +549,7 @@ func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
setQueuedRecordingMetadataHeaders(h, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,231 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"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 })
|
||||
for _, transport := range []string{"resumable", "legacy"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
const fileName = "1564859471123-device-key.mp4"
|
||||
payload := bytes.Repeat([]byte("video"), 1024)
|
||||
withRecording(t, fileName, payload)
|
||||
datedPath, err := utils.CreateRecordingPath("data/recordings", fileName, time.Unix(1564859471, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join("data/recordings", fileName), datedPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"1564859471123-device-key.mp4","device_key":"device-key","timestamp":1564859471123,"duration":27,"fps":25,"device_name":"camera-name","region_coordinates":"1-2-3-4","number_of_changes":"57"}`)
|
||||
fake := newFakeTus()
|
||||
var legacyBody []byte
|
||||
var legacyHeaders http.Header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if transport == "resumable" {
|
||||
fake.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
body, readErr := io.ReadAll(r.Body)
|
||||
if readErr != nil {
|
||||
t.Error(readErr)
|
||||
}
|
||||
legacyBody = body
|
||||
legacyHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
if transport == "legacy" {
|
||||
t.Setenv("AGENT_DISABLE_RESUMABLE_UPLOAD", "true")
|
||||
} else {
|
||||
t.Setenv("AGENT_DISABLE_RESUMABLE_UPLOAD", "false")
|
||||
}
|
||||
kstorageRetryCount, kstorageRetryTimeout = 0, 0
|
||||
vault := testVault(server.URL)
|
||||
config := &models.Configuration{Config: models.Config{
|
||||
Key: "changed-config-key",
|
||||
KStorage: &vault,
|
||||
KStorageSecondary: &models.KStorage{},
|
||||
}}
|
||||
uploaded, configured, err := UploadKerberosVault(config, fileName)
|
||||
if err != nil || !uploaded || !configured {
|
||||
t.Fatalf("UploadKerberosVault() = (%v, %v, %v), want success", uploaded, configured, err)
|
||||
}
|
||||
if transport == "resumable" {
|
||||
if got := fake.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("uploaded %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
posts := fake.requestsForMethod(http.MethodPost)
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("got %d creation requests, want 1", len(posts))
|
||||
}
|
||||
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
if metadata["filename"] != fileName || metadata["directory"] != "dir" || metadata["timestamp"] != "1564859471123" ||
|
||||
metadata["device"] != "device-key" || metadata["device_name"] != "camera-name" ||
|
||||
metadata["region_coordinates"] != "1-2-3-4" || metadata["number_of_changes"] != "57" {
|
||||
t.Fatalf("unexpected upload metadata: %#v", metadata)
|
||||
}
|
||||
} else if !bytes.Equal(legacyBody, payload) ||
|
||||
legacyHeaders.Get("X-Kerberos-Storage-FileName") != fileName ||
|
||||
legacyHeaders.Get("X-Kerberos-Storage-Directory") != "dir" ||
|
||||
legacyHeaders.Get("X-Kerberos-Storage-Device") != "device-key" ||
|
||||
legacyHeaders.Get(recordingDeviceNameHeader) != "camera-name" ||
|
||||
legacyHeaders.Get(recordingRegionHeader) != "1-2-3-4" ||
|
||||
legacyHeaders.Get(recordingChangesHeader) != "57" ||
|
||||
legacyHeaders.Get(recordingTimestampHeader) != "1564859471123" {
|
||||
t.Fatalf("unexpected legacy upload: %d bytes, headers %#v", len(legacyBody), legacyHeaders)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeUpload tracks the state of a single resumable upload on the fake server.
|
||||
type fakeUpload struct {
|
||||
size int64
|
||||
|
||||
@@ -964,7 +964,7 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
|
||||
// All days stored in this agent.
|
||||
days := []string{}
|
||||
latestEvents := []models.Media{}
|
||||
files, err := utils.ReadDirectory(recordingDirectory)
|
||||
files, err := utils.ReadRecordingDirectory(recordingDirectory)
|
||||
if err == nil {
|
||||
events := utils.GetSortedDirectory(files)
|
||||
|
||||
@@ -975,6 +975,8 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
|
||||
var eventFilter models.EventFilter
|
||||
eventFilter.NumberOfElements = 5
|
||||
latestEvents = utils.GetMediaFormatted(events, recordingDirectory, configuration, eventFilter) // will get 5 latest recordings.
|
||||
} else {
|
||||
log.WithError(err).Error("Could not list recordings for Agent status")
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
@@ -1007,7 +1009,7 @@ func GetLatestEvents(c *gin.Context, configDirectory string, configuration *mode
|
||||
eventFilter.NumberOfElements = 10
|
||||
}
|
||||
recordingDirectory := configDirectory + "/data/recordings"
|
||||
files, err := utils.ReadDirectory(recordingDirectory)
|
||||
files, err := utils.ReadRecordingDirectory(recordingDirectory)
|
||||
if err == nil {
|
||||
events := utils.GetSortedDirectory(files)
|
||||
// We will get all recordings from the directory (as defined by the filter).
|
||||
@@ -1036,7 +1038,7 @@ func GetLatestEvents(c *gin.Context, configDirectory string, configuration *mode
|
||||
// @Success 200
|
||||
func GetDays(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
|
||||
recordingDirectory := configDirectory + "/data/recordings"
|
||||
files, err := utils.ReadDirectory(recordingDirectory)
|
||||
files, err := utils.ReadRecordingDirectory(recordingDirectory)
|
||||
if err == nil {
|
||||
events := utils.GetSortedDirectory(files)
|
||||
days := utils.GetDays(events, recordingDirectory, configuration)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -11,12 +11,15 @@ const RecordingUploadMetadataExtension = ".metadata"
|
||||
// with a recording. New optional fields can be added without changing the queue
|
||||
// mechanism or breaking older agents.
|
||||
type RecordingUploadMetadata struct {
|
||||
FileName string `json:"filename"`
|
||||
DeviceKey string `json:"device_key"`
|
||||
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
|
||||
Duration uint64 `json:"duration"` // Milliseconds.
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
Encrypted bool `json:"encrypted,omitempty"`
|
||||
FileName string `json:"filename"`
|
||||
DeviceKey string `json:"device_key"`
|
||||
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
|
||||
Duration uint64 `json:"duration"` // Milliseconds.
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
Encrypted bool `json:"encrypted,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
RegionCoordinates string `json:"region_coordinates,omitempty"`
|
||||
NumberOfChanges string `json:"number_of_changes,omitempty"`
|
||||
}
|
||||
|
||||
// RecordingUploadMetadataFileName returns the queue marker name associated
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,9 @@ func NewQueue() *Queue {
|
||||
}
|
||||
|
||||
func (self *Queue) SetMaxGopCount(n int) {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
self.lock.Lock()
|
||||
self.maxgopcount = n
|
||||
self.lock.Unlock()
|
||||
@@ -97,20 +100,31 @@ func (self *Queue) WritePacket(pkt Packet) (err error) {
|
||||
self.lock.Lock()
|
||||
|
||||
self.buf.Push(pkt)
|
||||
if pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame {
|
||||
isVideoKeyframe := pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame
|
||||
if isVideoKeyframe {
|
||||
self.curgopcount++
|
||||
}
|
||||
|
||||
for self.curgopcount >= self.maxgopcount && self.buf.Count > 1 {
|
||||
pkt := self.buf.Pop()
|
||||
if pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame {
|
||||
self.curgopcount--
|
||||
}
|
||||
if self.curgopcount < self.maxgopcount {
|
||||
break
|
||||
for self.curgopcount > self.maxgopcount && self.buf.Count > 0 {
|
||||
for self.buf.Count > 0 {
|
||||
oldest := self.buf.Pop()
|
||||
if oldest.Idx == int8(self.videoidx) && oldest.IsKeyFrame {
|
||||
self.curgopcount--
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Discard the dependent frames from the evicted GOP as well. Keeping
|
||||
// them would let an overrun cursor resume on a P-frame whose reference
|
||||
// keyframe is no longer in the queue.
|
||||
for self.buf.Count > 0 {
|
||||
oldest := self.buf.Get(self.buf.Head)
|
||||
if oldest.Idx == int8(self.videoidx) && oldest.IsKeyFrame {
|
||||
break
|
||||
}
|
||||
self.buf.Pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
//println("shrink", self.curgopcount, self.maxgopcount, self.buf.Head, self.buf.Tail, "count", self.buf.Count, "size", self.buf.Size)
|
||||
|
||||
self.cond.Broadcast()
|
||||
|
||||
|
||||
@@ -107,3 +107,102 @@ func TestQueueCursorReadPacketContextDoesNotAllocateWhenPacketIsAvailable(t *tes
|
||||
t.Fatalf("ReadPacketContext() allocations = %.2f, want 0", allocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueEvictionKeepsKeyframeAtHead(t *testing.T) {
|
||||
queue := NewQueue()
|
||||
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue.SetMaxGopCount(2)
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
|
||||
{Idx: 1, IsAudio: true, Data: []byte("audio-0")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-1")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-2")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
oldest, err := queue.Oldest().ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !oldest.IsKeyFrame || string(oldest.Data) != "IDR-1" {
|
||||
t.Fatalf("oldest packet = %q (keyframe=%t), want IDR-1 keyframe", oldest.Data, oldest.IsKeyFrame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueWithSingleGOPRetainsCurrentKeyframe(t *testing.T) {
|
||||
queue := NewQueue()
|
||||
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue.SetMaxGopCount(1)
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
oldest, err := queue.Oldest().ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !oldest.IsKeyFrame || string(oldest.Data) != "IDR-1" {
|
||||
t.Fatalf("oldest packet = %q (keyframe=%t), want IDR-1 keyframe", oldest.Data, oldest.IsKeyFrame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaggingQueueCursorResumesAtKeyframe(t *testing.T) {
|
||||
queue := NewQueue()
|
||||
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue.SetMaxGopCount(2)
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
cursor := queue.Oldest()
|
||||
first, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(first.Data) != "IDR-0" {
|
||||
t.Fatalf("first packet = %q, want IDR-0", first.Data)
|
||||
}
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-1")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-2")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
resumed, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resumed.IsKeyFrame || string(resumed.Data) != "IDR-1" {
|
||||
t.Fatalf("resumed packet = %q (keyframe=%t), want IDR-1 keyframe", resumed.Data, resumed.IsKeyFrame)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestFilesServesFlatAndDatedRecordings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, layout := range []string{"flat", "dated"} {
|
||||
for _, encrypted := range []bool{false, true} {
|
||||
name := layout + "-plain"
|
||||
if encrypted {
|
||||
name = layout + "-encrypted"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
directory := t.TempDir()
|
||||
relativePath := fileName
|
||||
if layout == "dated" {
|
||||
relativePath = "2026/01/02/" + fileName
|
||||
}
|
||||
filePath := filepath.Join(directory, "data/recordings", relativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &models.Configuration{Config: models.Config{Encryption: &models.Encryption{}}}
|
||||
content := []byte("recording-payload")
|
||||
if encrypted {
|
||||
config.Config.Encryption.Recordings = "true"
|
||||
config.Config.Encryption.SymmetricKey = "0123456789abcdef0123456789abcdef"
|
||||
ciphertext, err := encryption.AesEncrypt(content, config.Config.Encryption.SymmetricKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content = []byte(ciphertext)
|
||||
}
|
||||
if err := os.WriteFile(filePath, content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/file/*filepath", func(c *gin.Context) { Files(c, directory, config) })
|
||||
for _, reference := range []string{relativePath, fileName} {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", "/file/"+reference, nil))
|
||||
if response.Code != 200 || response.Body.String() != "recording-payload" {
|
||||
t.Fatalf("GET %s = %d %q", reference, response.Code, response.Body.String())
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); got != "video/mp4" {
|
||||
t.Fatalf("Content-Type = %q, want video/mp4", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRejectsMissingAndOutsideRecordings(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(directory, "data/recordings"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "data/outside.mp4"), []byte("not a recording"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/file/*filepath", func(c *gin.Context) { Files(c, directory, &models.Configuration{}) })
|
||||
for _, reference := range []string{"missing.mp4", "../outside.mp4", "2026/01/02/missing.mp4"} {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", "/file/"+reference, nil))
|
||||
if response.Code != 404 {
|
||||
t.Fatalf("GET %s = %d, want 404", reference, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,12 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
|
||||
|
||||
// Will verify the hub settings.
|
||||
api.POST("/hub/verify", func(c *gin.Context) {
|
||||
cloud.VerifyHub(c)
|
||||
cloud.VerifyHub(c, configuration.Config.HubURI)
|
||||
})
|
||||
|
||||
// Will verify the persistence settings.
|
||||
api.POST("/persistence/verify", func(c *gin.Context) {
|
||||
cloud.VerifyPersistence(c, configDirectory)
|
||||
cloud.VerifyPersistence(c, configDirectory, configuration.Config.HubURI)
|
||||
})
|
||||
|
||||
// Will verify the secondary persistence settings.
|
||||
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/appleboy/gin-jwt/v2"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
@@ -160,9 +162,9 @@ func requestLogger() gin.HandlerFunc {
|
||||
func Files(c *gin.Context, configDirectory string, configuration *models.Configuration) {
|
||||
|
||||
// Get File
|
||||
filePath := configDirectory + "/data/recordings" + c.Param("filepath")
|
||||
_, err := os.Open(filePath)
|
||||
filePath, err := utils.ResolveRecordingPath(configDirectory+"/data/recordings", strings.TrimPrefix(c.Param("filepath"), "/"))
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("Could not resolve recording for playback")
|
||||
c.JSON(404, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
|
||||
+45
-80
@@ -191,51 +191,32 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
|
||||
count := 0
|
||||
for _, file := range files {
|
||||
fileName := file.Name()
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 6 {
|
||||
timestamp := fileParts[0]
|
||||
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err == nil {
|
||||
|
||||
if eventFilter.TimestampOffsetStart > 0 {
|
||||
// TimestampOffsetStart represents the newest lower bound to include.
|
||||
if timestampInt < eventFilter.TimestampOffsetStart {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If we have an offset we will check if we should skip or not
|
||||
if eventFilter.TimestampOffsetEnd > 0 {
|
||||
// Medias are sorted from new to older. TimestampOffsetEnd holds the oldest
|
||||
// timestamp of the previous batch of events. By doing this check, we make sure
|
||||
// to skip the previous batch.
|
||||
if timestampInt >= eventFilter.TimestampOffsetEnd {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
time := time.Unix(timestampInt, 0).In(loc)
|
||||
day := time.Format("02-01-2006")
|
||||
timeString := time.Format("15:04:05")
|
||||
shortDay := time.Format("Jan _2")
|
||||
|
||||
media := models.Media{
|
||||
Key: fileName,
|
||||
Path: recordingDirectory + "/" + fileName,
|
||||
CameraName: configuration.Config.Name,
|
||||
CameraKey: configuration.Config.Key,
|
||||
Day: day,
|
||||
ShortDay: shortDay,
|
||||
Time: timeString,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
filePaths = append(filePaths, media)
|
||||
count = count + 1
|
||||
if eventFilter.NumberOfElements > 0 && count >= eventFilter.NumberOfElements {
|
||||
break
|
||||
}
|
||||
}
|
||||
recordedAt, err := RecordingStartTime(fileName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
timestamp := recordedAt.Unix()
|
||||
if eventFilter.TimestampOffsetStart > 0 && timestamp < eventFilter.TimestampOffsetStart {
|
||||
continue
|
||||
}
|
||||
if eventFilter.TimestampOffsetEnd > 0 && timestamp >= eventFilter.TimestampOffsetEnd {
|
||||
continue
|
||||
}
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
localTime := recordedAt.In(loc)
|
||||
filePaths = append(filePaths, models.Media{
|
||||
Key: fileName,
|
||||
Path: recordingDirectory + "/" + fileName,
|
||||
CameraName: configuration.Config.Name,
|
||||
CameraKey: configuration.Config.Key,
|
||||
Day: localTime.Format("02-01-2006"),
|
||||
ShortDay: localTime.Format("Jan _2"),
|
||||
Time: localTime.Format("15:04:05"),
|
||||
Timestamp: strconv.FormatInt(timestamp, 10),
|
||||
})
|
||||
count++
|
||||
if eventFilter.NumberOfElements > 0 && count >= eventFilter.NumberOfElements {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filePaths
|
||||
@@ -244,18 +225,12 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
|
||||
func GetDays(files []os.FileInfo, recordingDirectory string, configuration *models.Configuration) []string {
|
||||
days := []string{}
|
||||
for _, file := range files {
|
||||
fileName := file.Name()
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 6 {
|
||||
timestamp := fileParts[0]
|
||||
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err == nil {
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
time := time.Unix(timestampInt, 0).In(loc)
|
||||
day := time.Format("02-01-2006")
|
||||
days = append(days, day)
|
||||
}
|
||||
recordedAt, err := RecordingStartTime(file.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
days = append(days, recordedAt.In(loc).Format("02-01-2006"))
|
||||
}
|
||||
uniqueDays := Unique(days)
|
||||
return uniqueDays
|
||||
@@ -278,10 +253,13 @@ func NumberOfFilesInDirectory(path string) int {
|
||||
return len(files)
|
||||
}
|
||||
|
||||
// NumberOfMP4sInDirectory returns the count of all files with mp4 extension in current directory
|
||||
// NumberOfMP4sInDirectory counts MP4 files, including dated subdirectories.
|
||||
func NumberOfMP4sInDirectory(path string) int {
|
||||
pattern := filepath.Join(path, "*.mp4")
|
||||
files, _ := filepath.Glob(pattern)
|
||||
files, err := ReadRecordingDirectory(path)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Could not count recordings")
|
||||
return 0
|
||||
}
|
||||
return len(files)
|
||||
}
|
||||
|
||||
@@ -409,25 +387,13 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
|
||||
|
||||
var files []string
|
||||
if fileInfo.IsDir() {
|
||||
// Create decrypted directory
|
||||
err = os.MkdirAll(directoryOrFile+"/decrypted", 0755)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
}
|
||||
dir, err := os.ReadDir(directoryOrFile)
|
||||
dir, err := ReadRecordingDirectory(directoryOrFile)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
}
|
||||
for _, file := range dir {
|
||||
// Check if file is not a directory
|
||||
if !file.IsDir() {
|
||||
// Check if an mp4 file
|
||||
if strings.HasSuffix(file.Name(), ".mp4") {
|
||||
files = append(files, directoryOrFile+"/"+file.Name())
|
||||
}
|
||||
}
|
||||
files = append(files, filepath.Join(directoryOrFile, file.Name()))
|
||||
}
|
||||
} else {
|
||||
files = append(files, directoryOrFile)
|
||||
@@ -449,13 +415,12 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
// Write decrypted content to file with appended .decrypted
|
||||
// Get filename split by / and get last element.
|
||||
fileParts := strings.Split(file, "/")
|
||||
fileName := fileParts[len(fileParts)-1]
|
||||
pathToFile := strings.Join(fileParts[:len(fileParts)-1], "/")
|
||||
|
||||
err = os.WriteFile(pathToFile+"/decrypted/"+fileName, []byte(decrypted), 0644)
|
||||
outputDirectory := filepath.Join(filepath.Dir(file), "decrypted")
|
||||
if err := os.MkdirAll(outputDirectory, 0o755); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(outputDirectory, filepath.Base(file)), []byte(decrypted), 0644)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RecordingFileName(timestamp int64, deviceKey string) (string, error) {
|
||||
if timestamp <= 0 {
|
||||
return "", fmt.Errorf("invalid recording timestamp %d", timestamp)
|
||||
}
|
||||
if deviceKey == "" || strings.ContainsAny(deviceKey, "/\\") {
|
||||
return "", fmt.Errorf("missing or invalid recording device key")
|
||||
}
|
||||
return strconv.FormatInt(timestamp, 10) + "-" + deviceKey + ".mp4", nil
|
||||
}
|
||||
|
||||
func RecordingStartTime(name string) (time.Time, error) {
|
||||
base := filepath.Base(name)
|
||||
if !strings.HasSuffix(base, ".mp4") {
|
||||
return time.Time{}, fmt.Errorf("invalid recording filename %q", name)
|
||||
}
|
||||
timestamp, deviceKey, short := strings.Cut(strings.TrimSuffix(base, ".mp4"), "-")
|
||||
if short && deviceKey != "" {
|
||||
if millis, err := strconv.ParseInt(timestamp, 10, 64); err == nil && millis > 0 {
|
||||
return time.UnixMilli(millis), nil
|
||||
}
|
||||
}
|
||||
parts := strings.Split(base, "_")
|
||||
if len(parts) == 6 {
|
||||
seconds, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && seconds > 0 {
|
||||
return time.Unix(seconds, 0), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid recording filename %q", name)
|
||||
}
|
||||
|
||||
func CreateRecordingPath(directory, fileName string, recordedAt time.Time) (string, error) {
|
||||
if !filepath.IsLocal(fileName) || fileName == "." || filepath.Base(fileName) != fileName || strings.ContainsAny(fileName, "/\\") {
|
||||
return "", fmt.Errorf("invalid recording filename %q", fileName)
|
||||
}
|
||||
if recordedAt.UnixMilli() <= 0 {
|
||||
return "", fmt.Errorf("invalid recording start time %s", recordedAt)
|
||||
}
|
||||
directory = filepath.Join(directory, recordedAt.UTC().Format("2006/01/02"))
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create recording directory: %w", err)
|
||||
}
|
||||
return filepath.Join(directory, fileName), nil
|
||||
}
|
||||
|
||||
// ResolveRecordingPath accepts relative paths from listings and historical
|
||||
// basename-only references from upload markers and playback URLs.
|
||||
func ResolveRecordingPath(directory, name string) (string, error) {
|
||||
if !filepath.IsLocal(name) {
|
||||
return "", fmt.Errorf("invalid recording path %q", name)
|
||||
}
|
||||
filePath := filepath.Join(directory, name)
|
||||
info, err := os.Stat(filePath)
|
||||
if os.IsNotExist(err) && filepath.Base(name) == name {
|
||||
recordedAt, parseErr := RecordingStartTime(name)
|
||||
if parseErr == nil {
|
||||
filePath = filepath.Join(directory, recordedAt.UTC().Format("2006/01/02"), name)
|
||||
info, err = os.Stat(filePath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("recording %q is not a regular file", name)
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func OpenRecording(directory, name string) (*os.File, error) {
|
||||
filePath, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(filePath)
|
||||
}
|
||||
|
||||
func RemoveRecording(directory, name string) error {
|
||||
filePath, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
type recordingFileInfo struct {
|
||||
os.FileInfo
|
||||
relativePath string
|
||||
}
|
||||
|
||||
func (file recordingFileInfo) Name() string {
|
||||
return file.relativePath
|
||||
}
|
||||
|
||||
// ReadRecordingDirectory returns MP4s with root-relative names, preserving the
|
||||
// file information used by the existing sorting and media formatting helpers.
|
||||
func ReadRecordingDirectory(directory string) ([]os.FileInfo, error) {
|
||||
return readRecordingDirectory(directory, filepath.WalkDir)
|
||||
}
|
||||
|
||||
func readRecordingDirectory(directory string, walkDir func(string, fs.WalkDirFunc) error) ([]os.FileInfo, error) {
|
||||
files := []os.FileInfo{}
|
||||
err := walkDir(directory, func(filePath string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && filePath != directory {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() && entry.Name() == "decrypted" && filePath != directory {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !entry.Type().IsRegular() || filepath.Ext(entry.Name()) != ".mp4" {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
name, err := filepath.Rel(directory, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, recordingFileInfo{FileInfo: info, relativePath: filepath.ToSlash(name)})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestShortRecordingNamePreservesMillisecondsAndDeviceKey(t *testing.T) {
|
||||
const timestamp = int64(1767355200123)
|
||||
const deviceKey = "device-key_with-hyphens"
|
||||
name, err := RecordingFileName(timestamp, deviceKey)
|
||||
if err != nil || name != "1767355200123-device-key_with-hyphens.mp4" {
|
||||
t.Fatalf("RecordingFileName() = %q, %v", name, err)
|
||||
}
|
||||
other, err := RecordingFileName(timestamp+1, deviceKey)
|
||||
if err != nil || other == name {
|
||||
t.Fatalf("recordings within the same second must have distinct names: %q, %v", other, err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
filePath, err := CreateRecordingPath(directory, name, time.UnixMilli(timestamp))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(directory, "2026/01/02", name); filePath != want {
|
||||
t.Fatalf("path = %q, want %q", filePath, want)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil || resolved != filePath {
|
||||
t.Fatalf("ResolveRecordingPath() = %q, %v", resolved, err)
|
||||
}
|
||||
files, err := ReadRecordingDirectory(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &models.Configuration{Config: models.Config{Timezone: "UTC"}}
|
||||
media := GetMediaFormatted(files, directory, config, models.EventFilter{TimestampOffsetStart: timestamp / 1000})
|
||||
if len(media) != 1 || media[0].Key != "2026/01/02/"+name || media[0].Timestamp != "1767355200" {
|
||||
t.Fatalf("unexpected short-name media: %+v", media)
|
||||
}
|
||||
if days := GetDays(files, directory, config); !reflect.DeepEqual(days, []string{"02-01-2026"}) {
|
||||
t.Fatalf("unexpected short-name days: %v", days)
|
||||
}
|
||||
if err := RemoveRecording(directory, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, invalidKey := range []string{"", "camera/key", `camera\key`} {
|
||||
if _, err := RecordingFileName(timestamp, invalidKey); err == nil {
|
||||
t.Errorf("accepted invalid device key %q", invalidKey)
|
||||
}
|
||||
}
|
||||
if _, err := RecordingFileName(0, deviceKey); err == nil {
|
||||
t.Fatal("accepted invalid recording timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRecordingPathUsesRecordingUTCDate(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
recordedAt := time.Date(2026, time.January, 1, 1, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60))
|
||||
name := strconv.FormatInt(recordedAt.Unix(), 10) + "_3-123_camera_0-0-0-0_1_1000.mp4"
|
||||
filePath, err := CreateRecordingPath(directory, name, recordedAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(directory, "2025/12/31", name); filePath != want {
|
||||
t.Fatalf("path = %q, want %q", filePath, want)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reference := range []string{name, "2025/12/31/" + name} {
|
||||
got, err := ResolveRecordingPath(directory, reference)
|
||||
if err != nil || got != filePath {
|
||||
t.Fatalf("ResolveRecordingPath(%q) = %q, %v; want %q", reference, got, err, filePath)
|
||||
}
|
||||
file, err := OpenRecording(directory, reference)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
file.Close()
|
||||
if err != nil || string(content) != "video" {
|
||||
t.Fatalf("recording content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
if err := RemoveRecording(directory, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("recording still exists after removal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingPathsKeepFlatFilesAccessible(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
name := "legacy-recording.mp4"
|
||||
filePath := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(filePath, []byte("legacy"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil || got != filePath {
|
||||
t.Fatalf("ResolveRecordingPath() = %q, %v; want %q", got, err, filePath)
|
||||
}
|
||||
if err := RemoveRecording(directory, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingPathsReportInvalidAndMissingPaths(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
for _, name := range []string{"", ".", "..", "../outside.mp4", "/outside.mp4", "nested/recording.mp4", `nested\recording.mp4`} {
|
||||
if _, err := CreateRecordingPath(directory, name, time.Now()); err == nil {
|
||||
t.Errorf("CreateRecordingPath(%q) should reject invalid filename", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"", ".", "../outside.mp4", "/outside.mp4"} {
|
||||
if _, err := ResolveRecordingPath(directory, name); err == nil {
|
||||
t.Errorf("ResolveRecordingPath(%q) should reject invalid path", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"missing.mp4", "1700000000_missing.mp4", "2026/01/01/missing.mp4"} {
|
||||
if _, err := ResolveRecordingPath(directory, name); !os.IsNotExist(err) {
|
||||
t.Errorf("ResolveRecordingPath(%q) error = %v, want not-exist", name, err)
|
||||
}
|
||||
}
|
||||
blocked := filepath.Join(directory, "not-a-directory")
|
||||
if err := os.WriteFile(blocked, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateRecordingPath(blocked, "recording.mp4", time.Now()); err == nil {
|
||||
t.Fatal("expected directory creation error")
|
||||
}
|
||||
if _, err := CreateRecordingPath(directory, "recording.mp4", time.Time{}); err == nil {
|
||||
t.Fatal("expected invalid recording start time error")
|
||||
}
|
||||
if _, err := ReadRecordingDirectory(filepath.Join(directory, "missing")); !os.IsNotExist(err) {
|
||||
t.Fatalf("ReadRecordingDirectory() error = %v, want not-exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecordingDirectoryIncludesFlatAndDatedMedia(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
names := []string{
|
||||
"1700000000_6_7_8_9_10.mp4",
|
||||
"2026/01/02/1767355200_6_7_8_9_10.mp4",
|
||||
}
|
||||
for index, name := range names {
|
||||
filePath := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modified := time.Unix(int64(index+1), 0)
|
||||
if err := os.Chtimes(filePath, modified, modified); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "2026/01/02/ignore.txt"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(directory, "2026/01/02/decrypted"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "2026/01/02/decrypted/output.mp4"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := ReadRecordingDirectory(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 2 || NumberOfMP4sInDirectory(directory) != 2 {
|
||||
t.Fatalf("expected 2 MP4 recordings, got %d", len(files))
|
||||
}
|
||||
config := &models.Configuration{Config: models.Config{Timezone: "UTC", Name: "camera", Key: "device"}}
|
||||
files = GetSortedDirectory(files)
|
||||
media := GetMediaFormatted(files, directory, config, models.EventFilter{})
|
||||
if len(media) != 2 || media[0].Key != names[1] || media[1].Key != names[0] {
|
||||
t.Fatalf("unexpected media ordering or relative keys: %+v", media)
|
||||
}
|
||||
if media[0].Path != filepath.Join(directory, names[1]) || media[0].Timestamp != "1767355200" {
|
||||
t.Fatalf("unexpected nested media path or timestamp: %+v", media[0])
|
||||
}
|
||||
if got, want := GetDays(files, directory, config), []string{"02-01-2026", "14-11-2023"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("days = %v, want %v", got, want)
|
||||
}
|
||||
filtered := GetMediaFormatted(files, directory, config, models.EventFilter{TimestampOffsetStart: 1767355200})
|
||||
if len(filtered) != 1 || filtered[0].Key != names[1] {
|
||||
t.Fatalf("unexpected filtered nested media: %+v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecordingDirectoryContinuesAfterConcurrentDeletion(t *testing.T) {
|
||||
for _, origin := range []string{"Info", "WalkDir"} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
for _, name := range []string{"deleted.mp4", "surviving.mp4"} {
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := readRecordingDirectory(directory, func(root string, visit fs.WalkDirFunc) error {
|
||||
deleted := filepath.Join(root, entries[0].Name())
|
||||
if err := os.Remove(deleted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
if origin == "Info" {
|
||||
err = visit(deleted, entries[0], nil)
|
||||
} else {
|
||||
err = visit(deleted, nil, &fs.PathError{Op: "lstat", Path: deleted, Err: fs.ErrNotExist})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return visit(filepath.Join(root, entries[1].Name()), entries[1], nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 1 || files[0].Name() != "surviving.mp4" || files[0].Size() != 5 {
|
||||
t.Fatalf("expected surviving recording after concurrent deletion, got %v", files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type recordingDirEntryInfoError struct {
|
||||
fs.DirEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func (entry recordingDirEntryInfoError) Info() (fs.FileInfo, error) {
|
||||
return nil, entry.err
|
||||
}
|
||||
|
||||
func TestReadRecordingDirectoryReportsOtherScanErrors(t *testing.T) {
|
||||
for _, origin := range []string{"Info", "WalkDir"} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "recording.mp4")
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := &fs.PathError{Op: "lstat", Path: filePath, Err: fs.ErrPermission}
|
||||
files, err := readRecordingDirectory(directory, func(root string, visit fs.WalkDirFunc) error {
|
||||
if origin == "Info" {
|
||||
return visit(filePath, recordingDirEntryInfoError{DirEntry: entries[0], err: wantErr}, nil)
|
||||
}
|
||||
return visit(filePath, nil, wantErr)
|
||||
})
|
||||
if !errors.Is(err, fs.ErrPermission) || files != nil {
|
||||
t.Fatalf("scan = %v, %v; want permission error without partial results", files, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptDirectoryIncludesDatedRecordingsAndSkipsOutputs(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
payload := []byte("recording-payload")
|
||||
ciphertext, err := encryption.AesEncrypt(payload, string(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{"legacy.mp4", "2026/01/02/recording.mp4"}
|
||||
for _, name := range names {
|
||||
filePath := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte(ciphertext), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Decrypt(directory, key)
|
||||
Decrypt(directory, key)
|
||||
for _, name := range names {
|
||||
output := filepath.Join(directory, filepath.Dir(name), "decrypted", filepath.Base(name))
|
||||
content, err := os.ReadFile(output)
|
||||
if err != nil || string(content) != string(payload) {
|
||||
t.Fatalf("decrypted %q = %q, %v", name, content, err)
|
||||
}
|
||||
}
|
||||
if got := NumberOfMP4sInDirectory(directory); got != len(names) {
|
||||
t.Fatalf("recording count = %d, want %d (excluding decrypted outputs)", got, len(names))
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
const { hostname, host, protocol } = window.location;
|
||||
const websocketprotocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
const devAgentPort = process.env.REACT_APP_AGENT_API_PORT || '8082';
|
||||
|
||||
// Uncomment this when using codespaces or other special DNS names (which you can't control)
|
||||
// replace this with the DNS name of the kerberos agent server (the codespace url)
|
||||
@@ -9,9 +10,9 @@ const dev = {
|
||||
ENV: 'dev',
|
||||
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
|
||||
HOSTNAME: hostname,
|
||||
API_URL: `${protocol}//${hostname}:8080/api`,
|
||||
URL: `${protocol}//${hostname}:8080`,
|
||||
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
|
||||
API_URL: `${protocol}//${hostname}:${devAgentPort}/api`,
|
||||
URL: `${protocol}//${hostname}:${devAgentPort}`,
|
||||
WS_URL: `${websocketprotocol}//${hostname}:${devAgentPort}/ws`,
|
||||
MODE: window['env']['mode'],
|
||||
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
|
||||
// HOSTNAME: externalHost,
|
||||
|
||||
Reference in New Issue
Block a user