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' }) })
},
events: {
startMotion: () =>
startMotion: (payload = {}) =>
request('/events/motion/start', {
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' }) }),
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 lastMotionEventId = null;
let motionDetector = null;
let detectorMotionActive = false;
let autoMotionTransitionInFlight = false;
let cameraVideoElement = 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 = () => {
if (motionDetector) {
return motionDetector;
@@ -492,13 +526,22 @@ const ensureMotionDetector = () => {
onUpdate: ({ state, score, activeMotion, activeSince }) => {
const current = getAppState().motionDetection;
const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state;
const motionBecameActive = activeMotion && !detectorMotionActive;
const motionBecameInactive = !activeMotion && detectorMotionActive;
detectorMotionActive = activeMotion;
updateMotionDetectionRuntime({
state: nextState,
score,
lastTriggeredAt: activeMotion
? current.lastTriggeredAt ?? (activeSince ? new Date(activeSince).toISOString() : new Date().toISOString())
lastTriggeredAt: motionBecameActive
? activeSince
? new Date(activeSince).toISOString()
: new Date().toISOString()
: current.lastTriggeredAt
});
if (motionBecameActive || motionBecameInactive) {
void syncAutoMotionLifecycle({ activeMotion });
}
}
});
@@ -514,6 +557,7 @@ const shouldRunMotionDetector = () => {
return Boolean(
state.device?.role === 'camera' &&
state.motionDetection?.enabled &&
state.socketConnected &&
state.cameraPreviewReady &&
localCameraStream &&
cameraVideoElement
@@ -530,9 +574,15 @@ const applyMotionDetectionReadiness = () => {
return;
}
const pauseReason = getMotionDetectionPauseReason();
detectorMotionActive = false;
if (isAutoMotionEventActive()) {
void syncAutoMotionLifecycle({ activeMotion: false });
}
if (detector.isRunning()) {
detector.stop('idle');
addActivity('Motion Detection', 'Detector monitoring paused');
addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`);
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 }) => {
if (!streamId || !requesterDeviceId) {
throw new Error('Missing stream request context');
@@ -1606,13 +1757,7 @@ const actions = {
async startMotion() {
try {
const response = await api.events.startMotion();
await startCameraPreview();
await startLocalRecording();
lastMotionEventId = response.event.id;
setAppState({ isMotionActive: true });
pushToast('Motion Event Started', 'success');
addActivity('Motion', `Started event ${response.event.id}`);
await startMotionEvent({ source: 'manual' });
} catch (error) {
pushToast(error.message || 'Failed to start motion', 'error');
}
@@ -1621,19 +1766,7 @@ const actions = {
async endMotion() {
if (!lastMotionEventId) return;
try {
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(lastMotionEventId);
lastMotionEventId = null;
setAppState({ isMotionActive: false });
pushToast('Motion Ended', 'success');
addActivity('Motion', 'Ended event');
await endMotionEvent({ source: 'manual' });
} catch (error) {
pushToast(error.message || 'Failed to end motion', 'error');
}

View File

@@ -9,6 +9,7 @@ export const createInitialState = () => ({
deviceToken: null,
socketConnected: false,
isMotionActive: false,
activeMotionSource: null,
cameraStatus: 'idle',
cameraPreviewReady: false,
cameraInputDevices: [],