479 lines
12 KiB
JavaScript
479 lines
12 KiB
JavaScript
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
// @ts-nocheck
|
|
|
|
const PAGE_PATHS = {
|
|
auth: '/auth/login',
|
|
onboarding: '/app/onboarding',
|
|
camera: '/app/camera',
|
|
client: '/app/client',
|
|
activity: '/app/activity',
|
|
settings: '/app/settings'
|
|
};
|
|
|
|
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
|
|
const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings';
|
|
|
|
export const INVALID_DEVICE_TOKEN_ERRORS = new Set([
|
|
'Missing device token',
|
|
'Invalid device token',
|
|
'Device not found',
|
|
'Token role does not match device role'
|
|
]);
|
|
|
|
export const MOTION_DETECTION_PROFILES = {
|
|
low_power: {
|
|
profile: 'low_power',
|
|
label: 'Low Power',
|
|
description: 'Least heat and battery usage, slower trigger response.',
|
|
sampleIntervalMs: 1400,
|
|
burstIntervalMs: 500,
|
|
triggerThreshold: 0.15,
|
|
releaseThreshold: 0.05,
|
|
consecutiveTriggerFrames: 3,
|
|
cooldownMs: 12000,
|
|
minimumEventMs: 6000
|
|
},
|
|
balanced: {
|
|
profile: 'balanced',
|
|
label: 'Balanced',
|
|
description: 'Recommended default for a plugged-in foreground browser.',
|
|
sampleIntervalMs: 1000,
|
|
burstIntervalMs: 300,
|
|
triggerThreshold: 0.12,
|
|
releaseThreshold: 0.04,
|
|
consecutiveTriggerFrames: 3,
|
|
cooldownMs: 9000,
|
|
minimumEventMs: 6000
|
|
},
|
|
responsive: {
|
|
profile: 'responsive',
|
|
label: 'Responsive',
|
|
description: 'Faster trigger response with higher CPU and thermal cost.',
|
|
sampleIntervalMs: 700,
|
|
burstIntervalMs: 220,
|
|
triggerThreshold: 0.1,
|
|
releaseThreshold: 0.035,
|
|
consecutiveTriggerFrames: 2,
|
|
cooldownMs: 7000,
|
|
minimumEventMs: 6000
|
|
}
|
|
};
|
|
|
|
export const MAX_STREAM_DIAGNOSTIC_SESSIONS = 12;
|
|
export const MAX_STREAM_DIAGNOSTIC_ENTRIES = 24;
|
|
|
|
const normalizePath = (path) => path.replace(/\/+$/, '') || '/';
|
|
|
|
export const pageFromPath = (path) => {
|
|
switch (normalizePath(path)) {
|
|
case '/auth':
|
|
case '/auth/login':
|
|
case '/auth/signup':
|
|
return 'auth';
|
|
case '/app':
|
|
return 'app';
|
|
case '/app/onboarding':
|
|
return 'onboarding';
|
|
case '/app/camera':
|
|
return 'camera';
|
|
case '/app/client':
|
|
return 'client';
|
|
case '/app/activity':
|
|
return 'activity';
|
|
case '/app/settings':
|
|
return 'settings';
|
|
default:
|
|
return 'auth';
|
|
}
|
|
};
|
|
|
|
export const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client');
|
|
|
|
export const getMotionDetectionProfile = (profile) =>
|
|
MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced;
|
|
|
|
const getDefaultMotionDetectionState = () => ({
|
|
enabled: false,
|
|
profile: MOTION_DETECTION_PROFILES.balanced.profile,
|
|
state: 'idle',
|
|
score: 0,
|
|
debug: false,
|
|
lastTriggeredAt: null
|
|
});
|
|
|
|
const buildMotionDetectionState = (overrides = {}) => {
|
|
const defaults = getDefaultMotionDetectionState();
|
|
const nextProfile = getMotionDetectionProfile(overrides.profile ?? defaults.profile);
|
|
return {
|
|
...defaults,
|
|
...nextProfile,
|
|
...overrides,
|
|
profile: nextProfile.profile
|
|
};
|
|
};
|
|
|
|
const sanitizeMotionDetectionSettings = (value) => {
|
|
if (!value || typeof value !== 'object') {
|
|
return buildMotionDetectionState();
|
|
}
|
|
|
|
return buildMotionDetectionState({
|
|
enabled: Boolean(value.enabled),
|
|
profile: typeof value.profile === 'string' ? value.profile : undefined,
|
|
debug: Boolean(value.debug)
|
|
});
|
|
};
|
|
|
|
const normalizeMotionDetectionState = (value) => {
|
|
if (!value || typeof value !== 'object') {
|
|
return buildMotionDetectionState();
|
|
}
|
|
|
|
return buildMotionDetectionState({
|
|
enabled: Boolean(value.enabled),
|
|
profile: typeof value.profile === 'string' ? value.profile : undefined,
|
|
state: typeof value.state === 'string' ? value.state : undefined,
|
|
score: typeof value.score === 'number' ? value.score : undefined,
|
|
debug: Boolean(value.debug),
|
|
lastTriggeredAt: typeof value.lastTriggeredAt === 'string' ? value.lastTriggeredAt : null
|
|
});
|
|
};
|
|
|
|
export const createControllerShared = ({ api, getAppState, setAppState, patchAppState }) => {
|
|
const makeId = () => {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
};
|
|
|
|
const getCurrentPath = () => normalizePath(window.location.pathname);
|
|
|
|
const getPathForScreen = (screen, role) => {
|
|
if (screen === 'home') {
|
|
return PAGE_PATHS[getHomePageKeyForRole(role)];
|
|
}
|
|
return PAGE_PATHS[screen] || null;
|
|
};
|
|
|
|
const navigateToScreen = (screen, options = {}) => {
|
|
const { replace = false, role = getAppState().device?.role } = options;
|
|
const targetPath = getPathForScreen(screen, role);
|
|
if (!targetPath) return false;
|
|
|
|
if (getCurrentPath() !== normalizePath(targetPath)) {
|
|
if (replace) {
|
|
window.location.replace(targetPath);
|
|
} else {
|
|
window.location.assign(targetPath);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
setAppState({ page: pageFromPath(targetPath) });
|
|
return false;
|
|
};
|
|
|
|
const pushToast = (message, type = 'info') => {
|
|
const id = makeId();
|
|
patchAppState((state) => ({
|
|
toasts: [...state.toasts, { id, message, type }].slice(-6)
|
|
}));
|
|
|
|
setTimeout(() => {
|
|
patchAppState((state) => ({
|
|
toasts: state.toasts.filter((toast) => toast.id !== id)
|
|
}));
|
|
}, 3200);
|
|
};
|
|
|
|
const removeToast = (id) => {
|
|
patchAppState((state) => ({
|
|
toasts: state.toasts.filter((toast) => toast.id !== id)
|
|
}));
|
|
};
|
|
|
|
const addActivity = (type, message) => {
|
|
const item = {
|
|
id: makeId(),
|
|
type,
|
|
message,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
patchAppState((state) => ({
|
|
activityLog: [item, ...state.activityLog].slice(0, 200)
|
|
}));
|
|
};
|
|
|
|
const pruneStreamDiagnostics = (diagnostics) => {
|
|
const entries = Object.entries(diagnostics || {});
|
|
if (entries.length <= MAX_STREAM_DIAGNOSTIC_SESSIONS) {
|
|
return diagnostics;
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
entries
|
|
.sort(
|
|
(left, right) =>
|
|
new Date(right[1]?.updatedAt || 0).getTime() - new Date(left[1]?.updatedAt || 0).getTime()
|
|
)
|
|
.slice(0, MAX_STREAM_DIAGNOSTIC_SESSIONS)
|
|
);
|
|
};
|
|
|
|
const pushStreamDiagnostic = (streamSessionId, stage, message, level = 'info', meta = {}) => {
|
|
if (!streamSessionId || !stage || !message) {
|
|
return;
|
|
}
|
|
|
|
const createdAt = new Date().toISOString();
|
|
patchAppState((state) => {
|
|
const current = state.streamDiagnostics?.[streamSessionId] ?? {
|
|
streamSessionId,
|
|
cameraDeviceId: null,
|
|
updatedAt: createdAt,
|
|
entries: []
|
|
};
|
|
const next = {
|
|
...current,
|
|
...meta,
|
|
streamSessionId,
|
|
updatedAt: createdAt,
|
|
entries: [
|
|
{
|
|
id: makeId(),
|
|
stage,
|
|
message,
|
|
level,
|
|
createdAt
|
|
},
|
|
...(current.entries || [])
|
|
].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES)
|
|
};
|
|
|
|
return {
|
|
streamDiagnostics: pruneStreamDiagnostics({
|
|
...(state.streamDiagnostics || {}),
|
|
[streamSessionId]: next
|
|
})
|
|
};
|
|
});
|
|
};
|
|
|
|
const loadMotionDetectionSettings = () => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return buildMotionDetectionState();
|
|
}
|
|
|
|
try {
|
|
const saved = localStorage.getItem(MOTION_DETECTION_SETTINGS_STORAGE_KEY);
|
|
if (!saved) {
|
|
return buildMotionDetectionState();
|
|
}
|
|
return sanitizeMotionDetectionSettings(JSON.parse(saved));
|
|
} catch (error) {
|
|
console.error('Failed to load motion detection settings', error);
|
|
return buildMotionDetectionState();
|
|
}
|
|
};
|
|
|
|
const persistMotionDetectionSettings = (motionDetection) => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
localStorage.setItem(
|
|
MOTION_DETECTION_SETTINGS_STORAGE_KEY,
|
|
JSON.stringify({
|
|
enabled: Boolean(motionDetection?.enabled),
|
|
profile: motionDetection?.profile ?? MOTION_DETECTION_PROFILES.balanced.profile,
|
|
debug: Boolean(motionDetection?.debug)
|
|
})
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to save motion detection settings', error);
|
|
}
|
|
};
|
|
|
|
const updateMotionDetectionState = (updates) => {
|
|
const current = normalizeMotionDetectionState(getAppState().motionDetection);
|
|
const next =
|
|
typeof updates === 'function'
|
|
? normalizeMotionDetectionState({
|
|
...current,
|
|
...updates(current)
|
|
})
|
|
: normalizeMotionDetectionState({
|
|
...current,
|
|
...updates
|
|
});
|
|
|
|
setAppState({ motionDetection: next });
|
|
persistMotionDetectionSettings(next);
|
|
return next;
|
|
};
|
|
|
|
const readSavedDeviceRecord = () => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const saved = localStorage.getItem(DEVICE_STORAGE_KEY);
|
|
if (!saved) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(saved);
|
|
} catch (error) {
|
|
console.error('Failed to parse saved device', error);
|
|
localStorage.removeItem(DEVICE_STORAGE_KEY);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(
|
|
DEVICE_STORAGE_KEY,
|
|
JSON.stringify({
|
|
device,
|
|
deviceToken,
|
|
userId
|
|
})
|
|
);
|
|
};
|
|
|
|
const clearSavedDeviceRecord = () => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
localStorage.removeItem(DEVICE_STORAGE_KEY);
|
|
};
|
|
|
|
const applySavedDeviceState = (device, deviceToken) => {
|
|
setAppState({
|
|
device,
|
|
deviceToken,
|
|
onboardingForm: {
|
|
...getAppState().onboardingForm,
|
|
name: device?.name ?? '',
|
|
role: device?.role ?? 'client',
|
|
pushToken: ''
|
|
}
|
|
});
|
|
};
|
|
|
|
const clearDeviceState = () => {
|
|
setAppState({
|
|
device: null,
|
|
deviceToken: null,
|
|
socketConnected: false,
|
|
isMotionActive: false,
|
|
activeMotionSource: null,
|
|
cameraStatus: 'idle',
|
|
cameraPreviewReady: false,
|
|
linkedCameras: [],
|
|
recordings: [],
|
|
activeCameraDeviceId: null,
|
|
activeStreamSessionId: null,
|
|
openLinkedCameraMenuId: null,
|
|
cameraSessions: {},
|
|
connectedStreamSessionIds: [],
|
|
clientStreamMode: 'none',
|
|
clientPlaceholderText: 'Select a camera to view',
|
|
onboardingForm: {
|
|
...getAppState().onboardingForm,
|
|
name: '',
|
|
role: 'client',
|
|
pushToken: ''
|
|
}
|
|
});
|
|
};
|
|
|
|
const restoreSavedDeviceForSession = async (session, options = {}) => {
|
|
const { showMissingToast = false, showInvalidToast = false } = options;
|
|
const saved = readSavedDeviceRecord();
|
|
|
|
if (!saved) {
|
|
if (showMissingToast) {
|
|
pushToast('No saved device found', 'info');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const sessionUserId = session?.user?.id;
|
|
const savedUserId = typeof saved.userId === 'string' ? saved.userId : null;
|
|
const savedDeviceId = saved?.device?.id;
|
|
const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : '';
|
|
|
|
if (!sessionUserId || !savedDeviceId || !savedDeviceToken) {
|
|
clearSavedDeviceRecord();
|
|
clearDeviceState();
|
|
if (showInvalidToast) {
|
|
pushToast('Saved device is incomplete. Please register again.', 'error');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (savedUserId && savedUserId !== sessionUserId) {
|
|
clearSavedDeviceRecord();
|
|
clearDeviceState();
|
|
if (showInvalidToast) {
|
|
pushToast('Saved device belongs to a different account.', 'info');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const result = await api.devices.list();
|
|
const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId);
|
|
|
|
if (!matchingDevice) {
|
|
clearSavedDeviceRecord();
|
|
clearDeviceState();
|
|
if (showInvalidToast) {
|
|
pushToast('Saved device was not found for this account.', 'info');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
applySavedDeviceState(matchingDevice, savedDeviceToken);
|
|
persistSavedDeviceRecord({
|
|
device: matchingDevice,
|
|
deviceToken: savedDeviceToken,
|
|
userId: sessionUserId
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to restore saved device', error);
|
|
if (showInvalidToast) {
|
|
pushToast('Unable to restore saved device right now.', 'error');
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
makeId,
|
|
pushToast,
|
|
removeToast,
|
|
addActivity,
|
|
pushStreamDiagnostic,
|
|
loadMotionDetectionSettings,
|
|
updateMotionDetectionState,
|
|
navigateToScreen,
|
|
persistSavedDeviceRecord,
|
|
clearSavedDeviceRecord,
|
|
applySavedDeviceState,
|
|
clearDeviceState,
|
|
restoreSavedDeviceForSession
|
|
};
|
|
};
|