Wire automatic motion event lifecycle

This commit is contained in:
2026-03-08 15:15:00 +00:00
parent 72903a97eb
commit f6849f425c
3 changed files with 159 additions and 25 deletions

View File

@@ -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) })

View File

@@ -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');
} }

View File

@@ -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: [],