Wire automatic motion event lifecycle
This commit is contained in:
@@ -53,10 +53,10 @@ export const api = {
|
|||||||
end: (id) => request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) })
|
end: (id) => request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) })
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
startMotion: () =>
|
startMotion: (payload = {}) =>
|
||||||
request('/events/motion/start', {
|
request('/events/motion/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' })
|
body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion', ...payload })
|
||||||
}),
|
}),
|
||||||
endMotion: (id) => request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }),
|
endMotion: (id) => request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }),
|
||||||
finalizeRecording: (id, payload) => request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) })
|
finalizeRecording: (id, payload) => request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) })
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ let activeRecordingStartedAt = null;
|
|||||||
let activeRecordingStreamSessionId = null;
|
let activeRecordingStreamSessionId = null;
|
||||||
let lastMotionEventId = null;
|
let lastMotionEventId = null;
|
||||||
let motionDetector = null;
|
let motionDetector = null;
|
||||||
|
let detectorMotionActive = false;
|
||||||
|
let autoMotionTransitionInFlight = false;
|
||||||
|
|
||||||
let cameraVideoElement = null;
|
let cameraVideoElement = null;
|
||||||
let clientVideoElement = null;
|
let clientVideoElement = null;
|
||||||
@@ -481,6 +483,38 @@ const updateMotionDetectionRuntime = (updates) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAutoMotionEventActive = () => {
|
||||||
|
const state = getAppState();
|
||||||
|
return Boolean(state.isMotionActive && state.activeMotionSource === 'auto' && lastMotionEventId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMotionDetectionPauseReason = () => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||||
|
return 'page hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getAppState();
|
||||||
|
if (state.device?.role !== 'camera') {
|
||||||
|
return 'device is not a camera';
|
||||||
|
}
|
||||||
|
if (!state.motionDetection?.enabled) {
|
||||||
|
return 'detector disarmed';
|
||||||
|
}
|
||||||
|
if (!state.socketConnected) {
|
||||||
|
return 'realtime connection offline';
|
||||||
|
}
|
||||||
|
if (!state.cameraPreviewReady) {
|
||||||
|
return 'camera preview unavailable';
|
||||||
|
}
|
||||||
|
if (!localCameraStream) {
|
||||||
|
return 'camera stream unavailable';
|
||||||
|
}
|
||||||
|
if (!cameraVideoElement) {
|
||||||
|
return 'preview element unavailable';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
};
|
||||||
|
|
||||||
const ensureMotionDetector = () => {
|
const ensureMotionDetector = () => {
|
||||||
if (motionDetector) {
|
if (motionDetector) {
|
||||||
return motionDetector;
|
return motionDetector;
|
||||||
@@ -492,13 +526,22 @@ const ensureMotionDetector = () => {
|
|||||||
onUpdate: ({ state, score, activeMotion, activeSince }) => {
|
onUpdate: ({ state, score, activeMotion, activeSince }) => {
|
||||||
const current = getAppState().motionDetection;
|
const current = getAppState().motionDetection;
|
||||||
const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state;
|
const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state;
|
||||||
|
const motionBecameActive = activeMotion && !detectorMotionActive;
|
||||||
|
const motionBecameInactive = !activeMotion && detectorMotionActive;
|
||||||
|
detectorMotionActive = activeMotion;
|
||||||
updateMotionDetectionRuntime({
|
updateMotionDetectionRuntime({
|
||||||
state: nextState,
|
state: nextState,
|
||||||
score,
|
score,
|
||||||
lastTriggeredAt: activeMotion
|
lastTriggeredAt: motionBecameActive
|
||||||
? current.lastTriggeredAt ?? (activeSince ? new Date(activeSince).toISOString() : new Date().toISOString())
|
? activeSince
|
||||||
|
? new Date(activeSince).toISOString()
|
||||||
|
: new Date().toISOString()
|
||||||
: current.lastTriggeredAt
|
: current.lastTriggeredAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (motionBecameActive || motionBecameInactive) {
|
||||||
|
void syncAutoMotionLifecycle({ activeMotion });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,6 +557,7 @@ const shouldRunMotionDetector = () => {
|
|||||||
return Boolean(
|
return Boolean(
|
||||||
state.device?.role === 'camera' &&
|
state.device?.role === 'camera' &&
|
||||||
state.motionDetection?.enabled &&
|
state.motionDetection?.enabled &&
|
||||||
|
state.socketConnected &&
|
||||||
state.cameraPreviewReady &&
|
state.cameraPreviewReady &&
|
||||||
localCameraStream &&
|
localCameraStream &&
|
||||||
cameraVideoElement
|
cameraVideoElement
|
||||||
@@ -530,9 +574,15 @@ const applyMotionDetectionReadiness = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pauseReason = getMotionDetectionPauseReason();
|
||||||
|
detectorMotionActive = false;
|
||||||
|
if (isAutoMotionEventActive()) {
|
||||||
|
void syncAutoMotionLifecycle({ activeMotion: false });
|
||||||
|
}
|
||||||
|
|
||||||
if (detector.isRunning()) {
|
if (detector.isRunning()) {
|
||||||
detector.stop('idle');
|
detector.stop('idle');
|
||||||
addActivity('Motion Detection', 'Detector monitoring paused');
|
addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,6 +1144,107 @@ const uploadStandaloneMotionRecording = async (captureResult) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMotionStartPayload = (source = 'manual') =>
|
||||||
|
source === 'auto'
|
||||||
|
? { title: 'Automatic Motion', triggeredBy: 'auto_motion' }
|
||||||
|
: { title: 'Simulated Motion', triggeredBy: 'motion' };
|
||||||
|
|
||||||
|
const startMotionEvent = async ({ source = 'manual' } = {}) => {
|
||||||
|
if (getAppState().isMotionActive || lastMotionEventId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.events.startMotion(getMotionStartPayload(source));
|
||||||
|
await startCameraPreview();
|
||||||
|
await startLocalRecording();
|
||||||
|
lastMotionEventId = response.event.id;
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
setAppState({
|
||||||
|
isMotionActive: true,
|
||||||
|
activeMotionSource: source
|
||||||
|
});
|
||||||
|
|
||||||
|
if (source === 'auto') {
|
||||||
|
updateMotionDetectionRuntime({ lastTriggeredAt: startedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast(source === 'auto' ? 'Automatic motion event started' : 'Motion Event Started', 'success');
|
||||||
|
addActivity(
|
||||||
|
'Motion',
|
||||||
|
source === 'auto' ? `Automatic motion event started (${response.event.id})` : `Started event ${response.event.id}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endMotionEvent = async ({ source = 'manual' } = {}) => {
|
||||||
|
if (!lastMotionEventId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = lastMotionEventId;
|
||||||
|
const streamSessionId = activeRecordingStreamSessionId;
|
||||||
|
|
||||||
|
if (streamSessionId) {
|
||||||
|
await api.streams.end(streamSessionId);
|
||||||
|
addActivity('Stream', `Ended stream ${streamSessionId}`);
|
||||||
|
} else if (activeMediaRecorder?.state === 'recording') {
|
||||||
|
const captureResult = await stopLocalRecording();
|
||||||
|
await uploadStandaloneMotionRecording(captureResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.events.endMotion(eventId);
|
||||||
|
lastMotionEventId = null;
|
||||||
|
setAppState({
|
||||||
|
isMotionActive: false,
|
||||||
|
activeMotionSource: null
|
||||||
|
});
|
||||||
|
|
||||||
|
pushToast(source === 'auto' ? 'Automatic motion event ended' : 'Motion Ended', 'success');
|
||||||
|
addActivity(
|
||||||
|
'Motion',
|
||||||
|
source === 'auto' ? `Automatic motion event ended (${eventId})` : `Ended event ${eventId}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAutoMotionLifecycle = async ({ activeMotion }) => {
|
||||||
|
if (autoMotionTransitionInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMotion) {
|
||||||
|
if (getAppState().isMotionActive || lastMotionEventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoMotionTransitionInFlight = true;
|
||||||
|
try {
|
||||||
|
await startMotionEvent({ source: 'auto' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-start motion event', error);
|
||||||
|
pushToast(error.message || 'Failed to start automatic motion event', 'error');
|
||||||
|
} finally {
|
||||||
|
autoMotionTransitionInFlight = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAutoMotionEventActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoMotionTransitionInFlight = true;
|
||||||
|
try {
|
||||||
|
await endMotionEvent({ source: 'auto' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-end motion event', error);
|
||||||
|
pushToast(error.message || 'Failed to end automatic motion event', 'error');
|
||||||
|
} finally {
|
||||||
|
autoMotionTransitionInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCameraStreamRequest = async ({ streamId, requesterDeviceId }) => {
|
const handleCameraStreamRequest = async ({ streamId, requesterDeviceId }) => {
|
||||||
if (!streamId || !requesterDeviceId) {
|
if (!streamId || !requesterDeviceId) {
|
||||||
throw new Error('Missing stream request context');
|
throw new Error('Missing stream request context');
|
||||||
@@ -1606,13 +1757,7 @@ const actions = {
|
|||||||
|
|
||||||
async startMotion() {
|
async startMotion() {
|
||||||
try {
|
try {
|
||||||
const response = await api.events.startMotion();
|
await startMotionEvent({ source: 'manual' });
|
||||||
await startCameraPreview();
|
|
||||||
await startLocalRecording();
|
|
||||||
lastMotionEventId = response.event.id;
|
|
||||||
setAppState({ isMotionActive: true });
|
|
||||||
pushToast('Motion Event Started', 'success');
|
|
||||||
addActivity('Motion', `Started event ${response.event.id}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast(error.message || 'Failed to start motion', 'error');
|
pushToast(error.message || 'Failed to start motion', 'error');
|
||||||
}
|
}
|
||||||
@@ -1621,19 +1766,7 @@ const actions = {
|
|||||||
async endMotion() {
|
async endMotion() {
|
||||||
if (!lastMotionEventId) return;
|
if (!lastMotionEventId) return;
|
||||||
try {
|
try {
|
||||||
const streamSessionId = activeRecordingStreamSessionId;
|
await endMotionEvent({ source: 'manual' });
|
||||||
if (streamSessionId) {
|
|
||||||
await api.streams.end(streamSessionId);
|
|
||||||
addActivity('Stream', `Ended stream ${streamSessionId}`);
|
|
||||||
} else if (activeMediaRecorder?.state === 'recording') {
|
|
||||||
const captureResult = await stopLocalRecording();
|
|
||||||
await uploadStandaloneMotionRecording(captureResult);
|
|
||||||
}
|
|
||||||
await api.events.endMotion(lastMotionEventId);
|
|
||||||
lastMotionEventId = null;
|
|
||||||
setAppState({ isMotionActive: false });
|
|
||||||
pushToast('Motion Ended', 'success');
|
|
||||||
addActivity('Motion', 'Ended event');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast(error.message || 'Failed to end motion', 'error');
|
pushToast(error.message || 'Failed to end motion', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const createInitialState = () => ({
|
|||||||
deviceToken: null,
|
deviceToken: null,
|
||||||
socketConnected: false,
|
socketConnected: false,
|
||||||
isMotionActive: false,
|
isMotionActive: false,
|
||||||
|
activeMotionSource: null,
|
||||||
cameraStatus: 'idle',
|
cameraStatus: 'idle',
|
||||||
cameraPreviewReady: false,
|
cameraPreviewReady: false,
|
||||||
cameraInputDevices: [],
|
cameraInputDevices: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user