diff --git a/machinery/src/capture/main.go b/machinery/src/capture/main.go index 5994f74..51d8211 100644 --- a/machinery/src/capture/main.go +++ b/machinery/src/capture/main.go @@ -50,6 +50,55 @@ func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration } } +const ( + // manualRecordingHeartbeatTimeout is how long the agent keeps a manual + // (live-view / remote) recording alive after the LAST viewer heartbeat. The + // frontend re-sends the record command every ~15s while the user stays on the + // page; if several heartbeats are missed (the viewer closed the tab, went idle + // or lost connectivity) the recorder auto-stops the recording so the camera + // doesn't record forever when the "stop" message never arrives. + manualRecordingHeartbeatTimeout = 45 * time.Second + // manualRecordingMaxDuration caps a single manual recording so a forgotten + // record button can't record indefinitely even while the viewer keeps sending + // heartbeats. After this the recording auto-stops and the viewer must press + // record again to continue. + manualRecordingMaxDuration = 5 * time.Minute +) + +// manualRecordingExpired reports whether an active manual (live-view) recording +// has outlived its viewer heartbeat window or the maximum duration cap. When it +// has, it clears the manual-recording state (so the motion recorder lets the +// current clip close normally and broadcasts recording:false) and returns true. +// It is a no-op returning false when no manual recording is active. +func manualRecordingExpired(communication *models.Communication, now int64) bool { + if communication.IsRecordingManual.IsNotSet() { + return false + } + manualStart := communication.RecordingManualStart.Load() + maxDurationReached := manualStart > 0 && now-manualStart > manualRecordingMaxDuration.Milliseconds() + // The heartbeat timeout only applies once the viewer has proven it supports + // heartbeats (an older frontend that starts a recording but never heartbeats + // still records up to the max-duration cap instead of being cut off early). + heartbeatExpired := false + if communication.RecordingManualHeartbeatSeen.IsSet() { + lastHeartbeat := communication.RecordingManualHeartbeat.Load() + heartbeatExpired = lastHeartbeat > 0 && now-lastHeartbeat > manualRecordingHeartbeatTimeout.Milliseconds() + } + if !heartbeatExpired && !maxDurationReached { + return false + } + if heartbeatExpired { + log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.") + } else { + log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, maximum duration reached.") + } + communication.IsRecordingManual.UnSet() + communication.RecordingManualHeartbeat.Store(0) + communication.RecordingManualStart.Store(0) + communication.RecordingManualHeartbeatSeen.UnSet() + return true +} + func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) { autoClean := configuration.Config.AutoClean if autoClean != "true" { @@ -227,6 +276,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat // Start each capture session with manual recording off, so a leftover // request from before a restart/reconnect doesn't silently persist. communication.IsRecordingManual.UnSet() + communication.RecordingManualHeartbeat.Store(0) + communication.RecordingManualStart.Store(0) + communication.RecordingManualHeartbeatSeen.UnSet() if config.Capture.Recording == "false" { log.Log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.") @@ -674,7 +726,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat // motion timestamp every iteration so the post-recording timeout // never fires. The clip still rolls over at maxRecordingPeriod and // is restarted below, until the viewer stops the manual recording. - if communication.IsRecordingManual.IsSet() { + // It also auto-stops when the viewer's heartbeat lapses (closed page + // or idle) or the max remote-recording duration is reached, so a + // missed "stop" message can't keep the camera recording forever. + if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, now) { motionTimestamp = now } @@ -751,8 +806,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat // If the viewer still has a manual recording running, this clip just // rolled over at the max length — immediately kick off the next - // segment so recording stays continuous until they stop it. - if communication.IsRecordingManual.IsSet() { + // segment so recording stays continuous until they stop it. Skip the + // restart when the recording has expired (heartbeat lapsed or max + // duration reached), so it ends here instead of recording forever. + if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, time.Now().UnixMilli()) { select { case communication.HandleMotion <- models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000}: default: diff --git a/machinery/src/components/kerberos.go b/machinery/src/components/kerberos.go index e217ca5..350966b 100644 --- a/machinery/src/components/kerberos.go +++ b/machinery/src/components/kerberos.go @@ -76,6 +76,9 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model communication.HandleLiveHLS = make(chan string, 1) communication.IsConfiguring = abool.New() communication.IsRecordingManual = abool.New() + communication.RecordingManualHeartbeat = &atomic.Int64{} + communication.RecordingManualStart = &atomic.Int64{} + communication.RecordingManualHeartbeatSeen = abool.New() cameraSettings := &models.Camera{} diff --git a/machinery/src/models/communication.go b/machinery/src/models/communication.go index b47cb74..6157f3b 100644 --- a/machinery/src/models/communication.go +++ b/machinery/src/models/communication.go @@ -52,12 +52,30 @@ type Communication struct { // recorder keeps recording (it does not auto-close on the post-recording // timeout) until the viewer stops it again. It is independent of motion // detection so it also works when nothing is moving. - IsRecordingManual *abool.AtomicBool - Queue *packets.Queue - SubQueue *packets.Queue - Image string - CameraConnected bool - MainStreamConnected bool - SubStreamConnected bool - HasBackChannel bool + IsRecordingManual *abool.AtomicBool + // RecordingManualHeartbeat holds the unix-milliseconds timestamp of the last + // heartbeat received from the live view while a manual recording is active. + // The frontend re-sends the record command every few seconds while the user + // stays on the page; if the heartbeats stop (the viewer closed the tab, went + // idle or lost connectivity) the recorder auto-stops the manual recording so + // it can't record forever when the "stop" message never arrives. + RecordingManualHeartbeat *atomic.Int64 + // RecordingManualStart holds the unix-milliseconds timestamp at which the + // current manual recording started. It bounds a manual recording to a maximum + // duration (see capture.manualRecordingMaxDuration) so a forgotten record + // button can't record indefinitely even while the viewer stays active. + RecordingManualStart *atomic.Int64 + // RecordingManualHeartbeatSeen is set once the current manual recording has + // received at least one heartbeat, i.e. the viewer proved it supports + // heartbeating. Only then does the recorder enforce the heartbeat timeout; a + // viewer that starts a recording but never heartbeats (an older frontend) + // still records up to the max-duration cap instead of being cut off early. + RecordingManualHeartbeatSeen *abool.AtomicBool + Queue *packets.Queue + SubQueue *packets.Queue + Image string + CameraConnected bool + MainStreamConnected bool + SubStreamConnected bool + HasBackChannel bool } diff --git a/machinery/src/models/mqtt.go b/machinery/src/models/mqtt.go index 31e85e1..f64710e 100644 --- a/machinery/src/models/mqtt.go +++ b/machinery/src/models/mqtt.go @@ -154,6 +154,15 @@ type RecordPayload struct { // recording (and keeps it running), false stops it. Older clients that only // send a timestamp default to false; the live view always sets it explicitly. Recording bool `json:"recording"` + // Heartbeat marks a keep-alive re-send (with Recording=true) from a viewer + // that supports heartbeating, as opposed to the initial start (the record + // button). While a user stays on the page the live view re-sends the record + // command every few seconds; the agent uses this flag to (a) refresh the + // recording's keep-alive without restarting an already auto-stopped clip from + // a stray heartbeat, and (b) only enable the heartbeat-timeout auto-stop once + // it has actually seen a heartbeat — so older viewers that never heartbeat + // still record up to the max-duration cap instead of being cut off early. + Heartbeat bool `json:"heartbeat"` } // We received a preset position request, we'll request it through onvif and send it back. diff --git a/machinery/src/routers/mqtt/main.go b/machinery/src/routers/mqtt/main.go index 40e7714..51fed31 100644 --- a/machinery/src/routers/mqtt/main.go +++ b/machinery/src/routers/mqtt/main.go @@ -381,23 +381,46 @@ func HandleRecording(mqttClient mqtt.Client, hubKey string, payload models.Paylo } if recordPayload.Recording { - // Start a manual recording from the live view (record button). Keep it - // running until the viewer stops it again — the motion recorder honours - // communication.IsRecordingManual and won't auto-close on the - // post-recording timeout while it's set. We also inject a motion event - // so the recording starts immediately, even when nothing is moving. - log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.") - communication.IsRecordingManual.Set() - select { - case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}: - default: - log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.") + now := time.Now().UnixMilli() + if recordPayload.Heartbeat { + // Keep-alive from a viewer that supports heartbeats. Only refresh while + // a manual recording is actually running; if it already auto-stopped + // (heartbeat timeout / max duration) we IGNORE it so a stray heartbeat + // can't restart a recording we just ended. Seeing a heartbeat also arms + // the recorder's heartbeat-timeout auto-stop. + if communication.IsRecordingManual.IsSet() { + communication.RecordingManualHeartbeat.Store(now) + communication.RecordingManualHeartbeatSeen.Set() + log.Log.Debug("routers.mqtt.main.HandleRecording(): manual recording heartbeat received.") + } else { + log.Log.Debug("routers.mqtt.main.HandleRecording(): ignoring heartbeat, no active manual recording.") + } + } else { + // Explicit start from the live view (record button). Start a manual + // recording and keep it running — the motion recorder honours + // communication.IsRecordingManual and won't auto-close on the + // post-recording timeout while it's set. We also inject a motion event + // so the recording starts immediately, even when nothing is moving. + communication.RecordingManualHeartbeat.Store(now) + if communication.IsRecordingManual.SetToIf(false, true) { + communication.RecordingManualStart.Store(now) + communication.RecordingManualHeartbeatSeen.UnSet() + log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.") + select { + case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}: + default: + log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.") + } + } } } else { // Stop the manual recording; the motion recorder closes the clip once the - // post-recording window elapses. + // post-recording window elapses. Clear the heartbeat/start markers too. log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording stopped.") communication.IsRecordingManual.UnSet() + communication.RecordingManualHeartbeat.Store(0) + communication.RecordingManualStart.Store(0) + communication.RecordingManualHeartbeatSeen.UnSet() } }