- Updated README.md to include instructions for using RTSPS camera URLs.
- Enhanced gortsplib.go to support insecure TLS configuration for self-signed certificates.
- Added unit tests for RTSPS TLS configuration in gortsplib_test.go.
Upgrade to Go 1.25, gortsplib v5, and refreshed Pion dependencies. Adapt RTSP client APIs and normalize RTP timestamps for FPS tracking, with coverage for PTS conversion.
Add paced, randomized RTP packetization with talkspurt markers and rollover-safe timestamps. Reconnect failed backchannel sessions with cancellable exponential backoff, initialize audio channels during bootstrap, and cover packetizer behavior with tests.
Remove exact duplicate IDR NALUs during normalization and drop repeated keyframes within a short timestamp window. Add normalization statistics, logging, reset handling, and coverage for deduplication behavior.
Drop stale H.264 packets until a recent keyframe arrives, with lifecycle logging and slow MoQ write diagnostics. Add focused FrameGate tests and configure the UI package registry.
Switch devcontainers to Debian Trixie for the required glibc version and add automated MoQ package tests, Agent linking, and version checks through a script and VS Code task.
Adds a dedicated MoQ build path that publishes H.264 live streams to a configurable relay, with retry handling, stream selection, Annex B framing, and Docker packaging. Standard builds retain a no-op implementation.
The upload marker now carries filename, device key, timestamp and duration alongside FPS, populated from the finalized MP4 at recording time.
Uploads propagate the new fields: legacy uploads add X-Kerberos-Storage-Duration and X-Kerberos-Storage-Timestamp headers, and resumable (tus) uploads include duration and timestamp in Upload-Metadata. setQueuedRecordingFPSHeader is renamed to setQueuedRecordingMetadataHeaders, and decoding of historical markers remains backwards compatible.
Replace plain-text FPS upload markers (named after the recording file) with JSON-encoded .metadata files via a new models.RecordingUploadMetadata type. This makes the marker format extensible for future fields beyond FPS.
Backward compatibility is preserved: cleanup, upload, and FPS-lookup code now check both the new .metadata marker and the legacy same-named marker, so recordings queued by older agents are still recognized as pending and their FPS still honored.
Instead of snapshotting the configured IP camera FPS, derive the average frame rate from the samples actually committed to the finalized MP4.
Adds MP4.AverageFPS(), backed by a SampleCount that is now incremented when a video sample is successfully written, and passes that value to queueRecordingForUpload. Validation (0 < fps <= 240, finite) moves to the numeric value, and unknown FPS still produces an empty, backwards-compatible marker.
Treat nil, zero, and negative pixel-change thresholds as the historical default of 150, removing the disabled-motion behavior and adding configuration tests.
Two fixes for cameras flipping to offline while capture is healthy:
- Heartbeat HTTP timeout (cloud.go): add a 30s Timeout to both
http.Client branches so a hung heartbeat POST can no longer stall the
heartbeat loop past the 180s window Hub uses to mark a camera offline.
- Bounded TUS retry loop (tus_client.go): refresh the retry budget only
on genuine net progress by tracking a highWaterOffset across all
attempts (vs a per-attempt startHighWater snapshot). A vault that keeps
resetting the offset (persistent 409 ERR_MISMATCHED_OFFSET) now gives
up after maxAttempts and re-queues instead of re-uploading the first
chunk forever and saturating the uplink.
Adds regression test TestUploadVaultResumable_MismatchedOffsetGivesUp
with a loseProgress fake-server mode reproducing the cross-replica
offset-reset loop; asserts the upload terminates with a bounded PATCH
count. Root cause (vault-side cross-replica offset reset) remains
deferred to the vault repo.
Three defects in the dispatch path.
Deleted was still a trigger. The Initialized guard was a denylist of one
value, so a property removal with an active-looking payload passed
straight through. Both Initialized and Deleted are announcements about a
property, not motion starting, so accept the transitions instead:
Changed, and Unknown for the events that omit the optional attribute.
ev.Topic reached the log unmodified. It is camera-controlled, unbounded
and unfiltered, and logrus's coloured text formatter — the default —
writes the message without quoting, so an embedded newline forges whole
log entries. A compromised camera could fabricate ERROR lines or spoof
another device's id in the logs an operator is reading to diagnose that
camera. Escape control characters and bound the length; the reject path
logs every event received, so an oversized topic was also a cheap way to
evict a container's retained history.
The trigger line was logged before the send, so an event dropped on a
full channel or at shutdown left a line claiming a recording that never
started. Log it in the send case.
A camera replays the current state of every property topic as
PropertyOperation=Initialized whenever a pull-point subscription is
created. dispatchEvent looked only at Kind and State, so any motion
property that happened to be active at that moment counted as a fresh
trigger — meaning every reconnect restarts a recording, and a flapping
subscription manufactures motion with no motion.
Observed on a camera whose pull-point was being recreated every ~18s:
each recreate replayed ~90 property events, and once real motion made
the VMD property active the replays kept re-triggering it.
Rejects Initialized specifically rather than accepting only Changed.
PropertyOperation is optional per WS-Notification and absent on many
non-property events, which decode reports as PropertyUnknown; those are
real events and must still trigger.
dispatchEvent logged only rejected events, so the topic that actually
started a recording was invisible — the only way to identify it was to
enumerate every rejected topic and reason about what was left. On a
camera emitting 18 distinct topics that is not a diagnosis.
Log the Kind and topic on the dispatch path too, at debug, matching the
reject line's shape so both sides of the decision grep the same way.
Audit against CLAUDE.md's 'default to no comments; only when WHY is
non-obvious'. Net ~30 lines removed.
Dropped (rot-prone or redundant)
--------------------------------
* 'matching what the pixel-diff detector emits' — references a
sibling file's behaviour.
* 'Timestamp in seconds matches what computervision/main.go emits;
downstream consumers (capture/main.go) tolerate...' — both
cross-file references; classic 'will rot when the sibling
changes'.
* 'Motion-stop wiring into the recorder state machine is tracked as
a follow-up; today the recorder uses a fixed PostRecording
timeout' — PR-description content masquerading as a code comment.
* 'happens only on ctx cancel today (library handles its own
reconnect)' — 'today' is a red flag; either drop or assert via
test, not narrate.
* dispatchEvent's first paragraph restating what the function does.
* runStreamOnce's first sentence (WHAT).
* isONVIFMotionEnabled's reference to sibling Capture fields
('unlike Recording / Motion / Snapshots which default to
enabled').
Kept (real WHYs)
----------------
* The shutdown-race rationale on dispatchEvent's ctx guards.
* logStreamError's severity-mapping rationale.
* The library-handles-reconnect-but-not-initial-connect rationale
for the backoff constants.
* The recovering-flag rationale (on-call ops use case).
* The flag-read-once invariant on HandleONVIFEventStream.