From 3e4fe0c6fc6a9e995f1c1b01449e64c76d177ab6 Mon Sep 17 00:00:00 2001 From: Pliable Pixels Date: Sat, 1 Aug 2026 14:58:48 -0400 Subject: [PATCH] fix: watch continuous-recording monitors on Live Activity Live Activity skipped monitors set to record continuously, on the premise that a camera always inside an event is always alarming and would sit on the page forever. ZoneMinder does not work that way: alarm state comes from the motion score alone, never from recording mode. Verified against 1.39.18 with a monitor mid-`Continuous` event, which reported IDLE across ~48s of polling and only alarmed on motion. Servers predating the 1.37 removal of TAPE report TAPE while quietly recording, and isAlarmingState already rejects that. So the exclusion hid real alarms and protected against nothing. Removes the filter, the `liveActivityWatchContinuousIds` opt-in list it needed, the per-row hint, and `isContinuousRecording`, now unused. The page-specific ignore list already covers hiding a camera by choice. Refs #313 --- agents/project/domain-context.md | 7 + app/.lint-baseline.json | 2 +- .../LiveActivitySettingsDialog.tsx | 95 +++---------- .../LiveActivitySettingsDialog.test.tsx | 130 ++---------------- .../monitor/__tests__/monitor-status.test.ts | 44 ------ app/src/lib/monitor/monitor-status.ts | 22 --- app/src/locales/de/translation.json | 1 - app/src/locales/en/translation.json | 1 - app/src/locales/es/translation.json | 1 - app/src/locales/fr/translation.json | 1 - app/src/locales/zh/translation.json | 1 - app/src/pages/LiveActivity.tsx | 32 ++--- app/src/pages/__tests__/LiveActivity.test.tsx | 54 +++----- app/src/stores/settings.ts | 7 - app/tests/features/live-activity.feature | 7 + app/tests/steps/live-activity.steps.ts | 22 +++ docs/user-guide/live-activity.md | 2 +- 17 files changed, 102 insertions(+), 327 deletions(-) diff --git a/agents/project/domain-context.md b/agents/project/domain-context.md index ff8b4f96..d5498239 100644 --- a/agents/project/domain-context.md +++ b/agents/project/domain-context.md @@ -24,6 +24,13 @@ matching reality, fixing it is a protocol change like any rule edit. for it. `lib/security/url-credentials.ts` is the only place that knows how to find it; both the log sanitizer and the monitor settings UI go through it. +- Alarm state (`monitors/alarm/id:{id}/command:status`) comes from the motion + score alone, never from recording mode. A `Recording=Always` monitor with an + open `cause: Continuous` event still reports `0` (IDLE), verified against + 1.39.18; it reports ALARM/ALERT on motion like any other monitor. `TAPE` (4) + existed only before its removal in 1.37 dev, where a continuous recorder sat + in it while quiet. So "always recording" never means "always alarming", and + `isAlarmingState` covers the legacy case already. - Event Server v7.0.22 and later always sends a real `eid` in pushes. The historical fake-eid bug (a `Date.now()` value where an event id belongs) was app-side tray handling, not the ES. diff --git a/app/.lint-baseline.json b/app/.lint-baseline.json index 78450e51..3d1e166e 100644 --- a/app/.lint-baseline.json +++ b/app/.lint-baseline.json @@ -7,7 +7,7 @@ "react-hooks/globals": 1, "react-hooks/preserve-manual-memoization": 12, "react-hooks/refs": 22, - "react-hooks/set-state-in-effect": 15, + "react-hooks/set-state-in-effect": 14, "react-hooks/static-components": 9, "react-refresh/only-export-components": 8, "unused-eslint-disable-directive": 1 diff --git a/app/src/components/live-activity/LiveActivitySettingsDialog.tsx b/app/src/components/live-activity/LiveActivitySettingsDialog.tsx index 69065448..fbc893c1 100644 --- a/app/src/components/live-activity/LiveActivitySettingsDialog.tsx +++ b/app/src/components/live-activity/LiveActivitySettingsDialog.tsx @@ -6,10 +6,6 @@ * stays visible everywhere else. That is separate from the profile-wide * monitor exclusion (Settings > hidden monitors), which hides a monitor * everywhere. - * - * A monitor that records continuously is skipped by default, so its toggle - * drives a separate opt-in list instead of the ignore list: that keeps the - * automatic default distinguishable from a monitor the user turned off. */ import { useMemo, useState, type KeyboardEvent } from 'react'; @@ -21,8 +17,6 @@ import { Label } from '../ui/label'; import { Separator } from '../ui/separator'; import { Switch } from '../ui/switch'; import { useSettingsStore, mergeProfileSettings } from '../../stores/settings'; -import { useAuthStore } from '../../stores/auth'; -import { isContinuousRecording } from '../../lib/monitor/monitor-status'; import { LIVE_ACTIVITY } from '../../lib/zmninja-ng-constants'; import type { MonitorData } from '../../api/types'; @@ -133,7 +127,6 @@ export function LiveActivitySettingsDialog({ monitors, }: LiveActivitySettingsDialogProps) { const { t } = useTranslation(); - const zmVersion = useAuthStore((s) => s.version); const rawSettings = useSettingsStore( useShallow((state) => state.profileSettings?.[profileId]) @@ -145,11 +138,6 @@ export function LiveActivitySettingsDialog({ [settings.liveActivityIgnoredMonitorIds] ); - const watchContinuousSet = useMemo( - () => new Set(settings.liveActivityWatchContinuousIds), - [settings.liveActivityWatchContinuousIds] - ); - const pollField = useClampedNumberField( settings.liveActivityPollSeconds, LIVE_ACTIVITY.minPollSeconds, @@ -184,32 +172,6 @@ export function LiveActivitySettingsDialog({ useSettingsStore.getState().updateProfileSettings(profileId, { liveActivityIgnoredMonitorIds: next }); }; - // A continuous recorder is off by default, so its toggle drives the opt-in - // list instead: on means watched, which is the inverse of the ignore list. - // - // Turning one on also clears any ignore entry for it. Both lists exclude the - // monitor and the ignore list wins, so without this the switch would flip on - // while the page kept excluding it, and the row would be a dead control: it - // no longer writes the ignore list, so nothing left in the UI could clear - // that entry. Reachable in one click by anyone who ignored a continuous - // monitor before it started being skipped by default. - const handleContinuousToggle = (monitorId: string, watched: boolean) => { - const current = settings.liveActivityWatchContinuousIds; - const next = watched - ? current.includes(monitorId) - ? current - : [...current, monitorId] - : current.filter((id) => id !== monitorId); - useSettingsStore.getState().updateProfileSettings(profileId, { - liveActivityWatchContinuousIds: next, - ...(watched && { - liveActivityIgnoredMonitorIds: settings.liveActivityIgnoredMonitorIds.filter( - (id) => id !== monitorId - ), - }), - }); - }; - return ( @@ -307,46 +269,23 @@ export function LiveActivitySettingsDialog({

{t('live_activity.ignore_list_empty')}

) : (
- {monitors.map(({ Monitor }) => { - const continuous = isContinuousRecording(Monitor, zmVersion); - return ( -
-
- - {continuous && ( -

- {t('live_activity.continuous_hint')} -

- )} -
- - continuous - ? handleContinuousToggle(Monitor.Id, checked) - : handleIgnoreToggle(Monitor.Id, checked) - } - data-testid={`live-activity-ignore-${Monitor.Id}`} - /> -
- ); - })} + {monitors.map(({ Monitor }) => ( +
+ + handleIgnoreToggle(Monitor.Id, checked)} + data-testid={`live-activity-ignore-${Monitor.Id}`} + /> +
+ ))}
)} diff --git a/app/src/components/live-activity/__tests__/LiveActivitySettingsDialog.test.tsx b/app/src/components/live-activity/__tests__/LiveActivitySettingsDialog.test.tsx index 8688a2ab..1e7b2a96 100644 --- a/app/src/components/live-activity/__tests__/LiveActivitySettingsDialog.test.tsx +++ b/app/src/components/live-activity/__tests__/LiveActivitySettingsDialog.test.tsx @@ -2,14 +2,13 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { LiveActivitySettingsDialog } from '../LiveActivitySettingsDialog'; import { useSettingsStore } from '../../../stores/settings'; -import { useAuthStore } from '../../../stores/auth'; const MONITORS = [ { Monitor: { Id: '3', Name: 'Front Door', Function: 'Modect' } }, { Monitor: { Id: '4', Name: 'Backyard', Function: 'Modect' } }, ]; -// Mocord records continuously on the pre-1.38 schema the tests below run on. +// Mocord records continuously on the pre-1.38 schema. const MONITORS_WITH_CONTINUOUS = [ ...MONITORS, { Monitor: { Id: '5', Name: 'Driveway', Function: 'Mocord' } }, @@ -18,7 +17,6 @@ const MONITORS_WITH_CONTINUOUS = [ describe('LiveActivitySettingsDialog', () => { beforeEach(() => { useSettingsStore.setState({ profileSettings: {} }); - useAuthStore.setState({ version: '1.36.33' }); }); it('persists a changed dwell value to the profile settings on blur', () => { @@ -57,119 +55,21 @@ describe('LiveActivitySettingsDialog', () => { ).toEqual(['4']); }); - // A continuous recorder is skipped by default, so its toggle drives the - // opt-in list rather than the ignore list. Seeding the ignore list instead - // would make the automatic default indistinguishable from a user's choice. - describe('continuous-recording monitors', () => { - function renderDialog() { - return render( - {}} - profileId="p1" - monitors={MONITORS_WITH_CONTINUOUS as never} - /> - ); - } - - it('shows a continuous recorder as off, and says why, without ignoring it', () => { - renderDialog(); - - expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute( - 'data-state', - 'unchecked' - ); - expect(screen.getByTestId('live-activity-continuous-hint-5')).toBeInTheDocument(); - expect(screen.queryByTestId('live-activity-continuous-hint-3')).not.toBeInTheDocument(); - expect( - useSettingsStore.getState().getProfileSettings('p1').liveActivityIgnoredMonitorIds - ).toEqual([]); - }); - - it('opts a continuous recorder in without touching the ignore list', () => { - renderDialog(); - - fireEvent.click(screen.getByTestId('live-activity-ignore-5')); - - const settings = useSettingsStore.getState().getProfileSettings('p1'); - expect(settings.liveActivityWatchContinuousIds).toEqual(['5']); - expect(settings.liveActivityIgnoredMonitorIds).toEqual([]); - }); - - it('drops a continuous recorder back out when it is toggled off again', () => { - useSettingsStore.getState().updateProfileSettings('p1', { - liveActivityWatchContinuousIds: ['5'], - }); - - renderDialog(); - expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute( - 'data-state', - 'checked' - ); - - fireEvent.click(screen.getByTestId('live-activity-ignore-5')); - - expect( - useSettingsStore.getState().getProfileSettings('p1').liveActivityWatchContinuousIds - ).toEqual([]); - }); - - // Reachable by anyone who ignored a continuous monitor before it started - // being skipped by default. Both lists exclude it and the ignore list - // wins, so a row that showed "on" while the page still excluded it, and - // that no longer wrote the ignore list, would be a control with no effect - // and no way back. - it('clears the ignore entry when an ignored continuous recorder is switched on', () => { - useSettingsStore.getState().updateProfileSettings('p1', { - liveActivityIgnoredMonitorIds: ['5'], - }); - - renderDialog(); - expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute( - 'data-state', - 'unchecked' - ); - - fireEvent.click(screen.getByTestId('live-activity-ignore-5')); - - // Both lists agree the monitor is watched, so the page really shows it. - const settings = useSettingsStore.getState().getProfileSettings('p1'); - expect(settings.liveActivityWatchContinuousIds).toEqual(['5']); - expect(settings.liveActivityIgnoredMonitorIds).toEqual([]); - expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute('data-state', 'checked'); - }); - - it('shows an ignored continuous recorder as off even when it is opted in', () => { - useSettingsStore.getState().updateProfileSettings('p1', { - liveActivityIgnoredMonitorIds: ['5'], - liveActivityWatchContinuousIds: ['5'], - }); - - renderDialog(); - - // The page excludes it, so the switch must not claim otherwise. - expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute( - 'data-state', - 'unchecked' - ); - }); + // A continuous recorder is treated like any other monitor: recording mode + // says nothing about what is alarming, so the row carries no special hint + // and no separate opt-in list (#313). + it('shows a continuous recorder as watched, with no hint of its own', () => { + render( + {}} + profileId="p1" + monitors={MONITORS_WITH_CONTINUOUS as never} + /> + ); - it('treats an alarm-only monitor normally on ZM 1.38+', () => { - useAuthStore.setState({ version: '1.38.0' }); - render( - {}} - profileId="p1" - monitors={ - [...MONITORS, { Monitor: { Id: '5', Name: 'Driveway', Function: 'Mocord', Recording: 'OnMotion' } }] as never - } - /> - ); - - expect(screen.queryByTestId('live-activity-continuous-hint-5')).not.toBeInTheDocument(); - expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute('data-state', 'checked'); - }); + expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute('data-state', 'checked'); + expect(screen.queryByText(/continuously/i)).not.toBeInTheDocument(); }); it('removes a monitor from the ignore list when it is toggled back on', () => { diff --git a/app/src/lib/monitor/__tests__/monitor-status.test.ts b/app/src/lib/monitor/__tests__/monitor-status.test.ts index 574e5ca4..f7849d25 100644 --- a/app/src/lib/monitor/__tests__/monitor-status.test.ts +++ b/app/src/lib/monitor/__tests__/monitor-status.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest'; import { getMonitorRunState, - isContinuousRecording, isMonitorStreamable, monitorDotColor, } from '../monitor-status'; @@ -216,49 +215,6 @@ describe('getMonitorRunState', () => { }); }); -describe('isContinuousRecording', () => { - describe('ZM 1.38+ reads Recording', () => { - const zmVersion = '1.38.0'; - - it('is continuous when Recording is Always', () => { - expect(isContinuousRecording(makeMonitor({ Recording: 'Always' }), zmVersion)).toBe(true); - }); - - it.each(['OnMotion', 'None'])('is not continuous when Recording is %s', (Recording) => { - expect(isContinuousRecording(makeMonitor({ Recording }), zmVersion)).toBe(false); - }); - - it('ignores a pre-1.38 Function that a 1.38 server still reports', () => { - // A 1.38 upgrade leaves Function populated, but Recording is what the - // server actually acts on, so Function must not decide this here. - const monitor = makeMonitor({ Function: 'Mocord', Recording: 'OnMotion' }); - expect(isContinuousRecording(monitor, zmVersion)).toBe(false); - }); - - it('is not continuous when a 1.38 server omits Recording', () => { - expect(isContinuousRecording(makeMonitor({ Recording: undefined }), zmVersion)).toBe(false); - }); - }); - - describe('pre-1.38 reads Function', () => { - it.each(['Record', 'Mocord'])('is continuous when Function is %s', (Function) => { - expect(isContinuousRecording(makeMonitor({ Function }), '1.36.33')).toBe(true); - }); - - it.each(['Modect', 'Monitor', 'Nodect', 'None'])( - 'is not continuous when Function is %s', - (Function) => { - expect(isContinuousRecording(makeMonitor({ Function }), '1.36.33')).toBe(false); - } - ); - - it('reads Function when zmVersion is unknown, ignoring Recording', () => { - const monitor = makeMonitor({ Function: 'Record', Recording: 'OnMotion' }); - expect(isContinuousRecording(monitor, null)).toBe(true); - }); - }); -}); - describe('isMonitorStreamable', () => { it('returns true for live and warning', () => { expect(isMonitorStreamable('live')).toBe(true); diff --git a/app/src/lib/monitor/monitor-status.ts b/app/src/lib/monitor/monitor-status.ts index b387cb97..30eb05dd 100644 --- a/app/src/lib/monitor/monitor-status.ts +++ b/app/src/lib/monitor/monitor-status.ts @@ -13,13 +13,6 @@ export type MonitorRunState = 'live' | 'warning' | 'offline' | 'disabled'; const ANALYSIS_FUNCTIONS = new Set(['Modect', 'Mocord', 'Nodect']); -/** - * Pre-1.38 Functions that write every captured frame to an event, not just the - * alarmed ones. Mocord is in both this set and ANALYSIS_FUNCTIONS: it records - * continuously *and* runs motion detection. - */ -const CONTINUOUS_FUNCTIONS = new Set(['Record', 'Mocord']); - function parseFps(fps: string | null | undefined): number { return parseFloat(fps ?? '0') || 0; } @@ -59,21 +52,6 @@ export function getMonitorRunState( return 'live'; } -/** - * True when the monitor records continuously rather than only on alarm. - * - * ZoneMinder 1.38 split Function into independent controls, so the answer - * lives in a different field either side of that line: `Recording` from 1.38 - * on, `Function` before it. A monitor that is always recording is always - * "in an event", which is why callers such as Live Activity treat it as noise - * rather than as something happening right now. - */ -export function isContinuousRecording(monitor: Monitor, zmVersion: string | null): boolean { - return isZmVersionAtLeast(zmVersion, '1.38.0') - ? monitor.Recording === 'Always' - : CONTINUOUS_FUNCTIONS.has(monitor.Function); -} - /** True when the monitor should be showing a video stream. */ export function isMonitorStreamable(state: MonitorRunState): boolean { return state === 'live' || state === 'warning'; diff --git a/app/src/locales/de/translation.json b/app/src/locales/de/translation.json index 8caf468e..2780eef9 100644 --- a/app/src/locales/de/translation.json +++ b/app/src/locales/de/translation.json @@ -1039,7 +1039,6 @@ "max_tiles_desc": "Weitere Monitore werden als Zähler zusammengefasst statt angezeigt.", "ignore_list_label": "Zu beobachtende Monitore", "ignore_list_desc": "Monitor ausschalten, um ihn nur auf dieser Seite zu verbergen; er bleibt überall sonst sichtbar.", - "continuous_hint": "Nimmt durchgehend auf und wird daher standardmäßig übersprungen.", "ignore_list_empty": "Keine Monitore verfügbar." }, "languages": { diff --git a/app/src/locales/en/translation.json b/app/src/locales/en/translation.json index cbaf0fb8..6270f6f9 100644 --- a/app/src/locales/en/translation.json +++ b/app/src/locales/en/translation.json @@ -1039,7 +1039,6 @@ "max_tiles_desc": "Extra monitors collapse into a count instead of rendering.", "ignore_list_label": "Monitors to watch", "ignore_list_desc": "Turn a monitor off to keep it off this page only; it stays visible everywhere else.", - "continuous_hint": "Records continuously, so it is skipped by default.", "ignore_list_empty": "No monitors available." }, "languages": { diff --git a/app/src/locales/es/translation.json b/app/src/locales/es/translation.json index c759c9ca..5216ba1b 100644 --- a/app/src/locales/es/translation.json +++ b/app/src/locales/es/translation.json @@ -1039,7 +1039,6 @@ "max_tiles_desc": "Los monitores adicionales se agrupan en un contador en vez de mostrarse.", "ignore_list_label": "Monitores a observar", "ignore_list_desc": "Desactiva un monitor para ocultarlo solo en esta página; sigue visible en el resto.", - "continuous_hint": "Graba de forma continua, por lo que se omite de forma predeterminada.", "ignore_list_empty": "No hay monitores disponibles." }, "languages": { diff --git a/app/src/locales/fr/translation.json b/app/src/locales/fr/translation.json index cbc7d5ae..6b344e97 100644 --- a/app/src/locales/fr/translation.json +++ b/app/src/locales/fr/translation.json @@ -1039,7 +1039,6 @@ "max_tiles_desc": "Les caméras en trop sont regroupées en un compteur au lieu d'être affichées.", "ignore_list_label": "Caméras à surveiller", "ignore_list_desc": "Désactivez une caméra pour la masquer seulement sur cette page; elle reste visible ailleurs.", - "continuous_hint": "Enregistre en continu, donc ignoré par défaut.", "ignore_list_empty": "Aucune caméra disponible." }, "languages": { diff --git a/app/src/locales/zh/translation.json b/app/src/locales/zh/translation.json index 6754efde..a4cf3f97 100644 --- a/app/src/locales/zh/translation.json +++ b/app/src/locales/zh/translation.json @@ -1039,7 +1039,6 @@ "max_tiles_desc": "超出的监控将合并为一个数字,不再显示。", "ignore_list_label": "要监看的监控", "ignore_list_desc": "关闭某个监控可使其不在本页显示,它仍会在其他页面可见。", - "continuous_hint": "持续录像,因此默认跳过。", "ignore_list_empty": "没有可用的监控。" }, "languages": { diff --git a/app/src/pages/LiveActivity.tsx b/app/src/pages/LiveActivity.tsx index 0505bfd6..e7b092e3 100644 --- a/app/src/pages/LiveActivity.tsx +++ b/app/src/pages/LiveActivity.tsx @@ -33,7 +33,6 @@ import { sameMonitorOrder, type ActiveMonitorEntry, } from '../lib/monitor/live-activity'; -import { isContinuousRecording } from '../lib/monitor/monitor-status'; import { runViewTransition } from '../lib/view-transition'; import type { MonitorAlarmState } from '../lib/monitor/alarm-state'; import { useFullscreenMode } from '../hooks/useFullscreenMode'; @@ -55,7 +54,6 @@ export default function LiveActivity() { const { currentProfile, settings } = useCurrentProfile(); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const accessToken = useAuthStore((s) => s.accessToken); - const zmVersion = useAuthStore((s) => s.version); const updateSettings = useSettingsStore((s) => s.updateProfileSettings); const bandwidth = useBandwidthSettings(); const gridContainerRef = useRef(null); @@ -69,28 +67,22 @@ export default function LiveActivity() { }); // Monitors this page is allowed to watch. The profile-wide exclusion is - // already applied inside getMonitors; this drops the page-specific ignores - // and, unless the user opted them back in, the continuous recorders. A - // monitor that always records is always in an event, so it would sit on this - // page permanently and crowd out the monitors that are actually alarming. - // The ignore list applies on top: an explicitly ignored monitor stays out - // whether or not it is also opted in here. + // already applied inside getMonitors, so all that is left is the + // page-specific ignore list. + // + // Recording mode is deliberately not consulted. A continuous recorder is + // always inside an event, but an event is not an alarm: ZoneMinder derives + // the alarm state from the motion score alone, so such a monitor reports + // IDLE at rest (verified against 1.39.18, #313) and ALARM on motion like any + // other. Servers before the 1.37 removal of TAPE report TAPE while + // recording, which isAlarmingState already rejects. Skipping these monitors + // only hid real alarms. const watchedIds = useMemo(() => { const ignored = new Set(settings.liveActivityIgnoredMonitorIds); - const watchContinuous = new Set(settings.liveActivityWatchContinuousIds); return (data?.monitors ?? []) - .filter( - ({ Monitor }) => - !ignored.has(Monitor.Id) && - (watchContinuous.has(Monitor.Id) || !isContinuousRecording(Monitor, zmVersion)) - ) + .filter(({ Monitor }) => !ignored.has(Monitor.Id)) .map(({ Monitor }) => Monitor.Id); - }, [ - data?.monitors, - settings.liveActivityIgnoredMonitorIds, - settings.liveActivityWatchContinuousIds, - zmVersion, - ]); + }, [data?.monitors, settings.liveActivityIgnoredMonitorIds]); const pollIntervalMs = resolvePollIntervalMs( settings.bandwidthMode, diff --git a/app/src/pages/__tests__/LiveActivity.test.tsx b/app/src/pages/__tests__/LiveActivity.test.tsx index c941686d..4f519d47 100644 --- a/app/src/pages/__tests__/LiveActivity.test.tsx +++ b/app/src/pages/__tests__/LiveActivity.test.tsx @@ -21,7 +21,6 @@ const env = vi.hoisted(() => ({ liveActivityDwellSeconds: 30, liveActivityMaxTiles: 12, liveActivityIgnoredMonitorIds: [] as string[], - liveActivityWatchContinuousIds: [] as string[], liveActivityIsFullscreen: false, bandwidthMode: 'normal', monitorGridCols: 2, @@ -139,7 +138,6 @@ describe('LiveActivity', () => { vi.clearAllMocks(); tileRenders.count = 0; env.settings.liveActivityIgnoredMonitorIds = []; - env.settings.liveActivityWatchContinuousIds = []; env.settings.liveActivityIsFullscreen = false; env.zmVersion = '1.36.33'; mockMonitors.mockResolvedValue(MONITORS as never); @@ -174,8 +172,11 @@ describe('LiveActivity', () => { expect(screen.getByRole('img', { name: 'Alarmed' })).toHaveAttribute('title', 'Alarmed'); }); - // A monitor that always records is always inside an event, so it would sit - // on this page forever and crowd out whatever is genuinely alarming. + // Recording mode says nothing about what is alarming, so this page does not + // read it. Verified against ZoneMinder 1.39.18 (#313): a monitor with an + // open `Continuous` event still reports IDLE, because state comes from the + // motion score alone. Servers before the 1.37 removal of TAPE report TAPE + // while continuously recording, which isAlarmingState already rejects. describe('continuous-recording monitors', () => { const WITH_CONTINUOUS = { monitors: [ @@ -190,18 +191,7 @@ describe('LiveActivity', () => { mockStatus.mockResolvedValue({ status: 2 } as never); }); - it('leaves a continuous recorder off the page by default', async () => { - render(, { wrapper }); - - await waitFor(() => { - expect(screen.getByText('Front Door')).toBeInTheDocument(); - }); - expect(screen.queryByText('Driveway')).not.toBeInTheDocument(); - }); - - it('watches a continuous recorder the user opted back in', async () => { - env.settings.liveActivityWatchContinuousIds = ['5']; - + it('watches a continuous recorder like any other monitor', async () => { render(, { wrapper }); await waitFor(() => { @@ -209,21 +199,7 @@ describe('LiveActivity', () => { }); }); - it('keeps an opted-in continuous recorder out when it is also ignored', async () => { - env.settings.liveActivityWatchContinuousIds = ['5']; - env.settings.liveActivityIgnoredMonitorIds = ['5']; - - render(, { wrapper }); - - await waitFor(() => { - expect(screen.getByText('Front Door')).toBeInTheDocument(); - }); - expect(screen.queryByText('Driveway')).not.toBeInTheDocument(); - }); - - it('reads Recording rather than Function on ZM 1.38+', async () => { - // The same monitor: pre-1.38 Function says continuous, 1.38 Recording - // says on-motion. The 1.38 field wins, so it is watched. + it('watches one reported through the 1.38 Recording field', async () => { env.zmVersion = '1.38.0'; mockMonitors.mockResolvedValue({ monitors: [ @@ -232,8 +208,8 @@ describe('LiveActivity', () => { Monitor: { Id: '5', Name: 'Driveway', - Function: 'Mocord', - Recording: 'OnMotion', + Recording: 'Always', + Analysing: 'Always', Capturing: 'Always', }, }, @@ -246,6 +222,17 @@ describe('LiveActivity', () => { expect(screen.getByText('Driveway')).toBeInTheDocument(); }); }); + + it('keeps a continuous recorder out when it is ignored', async () => { + env.settings.liveActivityIgnoredMonitorIds = ['5']; + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByText('Front Door')).toBeInTheDocument(); + }); + expect(screen.queryByText('Driveway')).not.toBeInTheDocument(); + }); }); it('shows the quiet empty state when nothing is alarming', async () => { @@ -649,7 +636,6 @@ describe('LiveActivity', () => { liveActivityDwellSeconds: 30, liveActivityMaxTiles: 12, liveActivityIgnoredMonitorIds: [], - liveActivityWatchContinuousIds: [], bandwidthMode: 'normal', monitorGridCols: 2, }, diff --git a/app/src/stores/settings.ts b/app/src/stores/settings.ts index 2c6791aa..c36bd9e7 100644 --- a/app/src/stores/settings.ts +++ b/app/src/stores/settings.ts @@ -165,12 +165,6 @@ export interface ProfileSettings { /** Live Activity: monitors that never appear on that page. Separate from the * profile-wide monitor exclusion, which hides a monitor everywhere. */ liveActivityIgnoredMonitorIds: string[]; - /** Live Activity: continuous-recording monitors the user opted back in to. - * They are skipped by default because a monitor that always records is - * always in an event, which says nothing about what is alarming now. An - * explicit opt-in list rather than seeding the ignore list above, so the - * default stays distinguishable from a deliberate choice. */ - liveActivityWatchContinuousIds: string[]; /** Live Activity: fullscreen state for that page. Separate from * montageIsFullscreen so the two pages do not share one fullscreen flag. */ liveActivityIsFullscreen: boolean; @@ -353,7 +347,6 @@ export const DEFAULT_SETTINGS: ProfileSettings = { liveActivityDwellSeconds: LIVE_ACTIVITY.defaultDwellSeconds, liveActivityMaxTiles: LIVE_ACTIVITY.defaultMaxTiles, liveActivityIgnoredMonitorIds: [], - liveActivityWatchContinuousIds: [], liveActivityIsFullscreen: false, // No group filter by default (show all monitors) selectedGroupId: null, diff --git a/app/tests/features/live-activity.feature b/app/tests/features/live-activity.feature index fa52934c..594d1124 100644 --- a/app/tests/features/live-activity.feature +++ b/app/tests/features/live-activity.feature @@ -12,6 +12,13 @@ Feature: Live Activity Then I should see the all-quiet message And the all-quiet message should name how many monitors are being watched + @all + Scenario: Every monitor the server offers is watched, whatever its recording mode + Then the all-quiet message should name how many monitors are being watched + When I open the Live Activity settings + Then every listed monitor should be switched on + And the number of listed monitors should match the watched count + @all Scenario: Fullscreen hides the page chrome and is remembered, without moving Montage When I enter Live Activity fullscreen diff --git a/app/tests/steps/live-activity.steps.ts b/app/tests/steps/live-activity.steps.ts index dbdfce62..0a9a3490 100644 --- a/app/tests/steps/live-activity.steps.ts +++ b/app/tests/steps/live-activity.steps.ts @@ -93,3 +93,25 @@ Then('the all-quiet message should name how many monitors are being watched', as expect(match, `expected a "Watching N monitor(s)" count in: ${text}`).not.toBeNull(); expect(Number(match![1])).toBeGreaterThan(0); }); + +// Recording mode does not decide what this page watches (#313): a continuously +// recording camera reports idle until motion, so it belongs here like any +// other. These two steps together fail if any monitor is excluded without the +// user asking, because the count and the switches would disagree with the list. +Then('every listed monitor should be switched on', async ({ page }) => { + const switches = page.getByTestId(/^live-activity-ignore-/); + const count = await switches.count(); + expect(count, 'expected the settings dialog to list at least one monitor').toBeGreaterThan(0); + for (let i = 0; i < count; i += 1) { + await expect(switches.nth(i)).toHaveAttribute('data-state', 'checked'); + } +}); + +Then('the number of listed monitors should match the watched count', async ({ page }) => { + const listed = await page.getByTestId(/^live-activity-ignore-/).count(); + await page.getByTestId('dialog-close-button').click(); + const text = await page.getByTestId('live-activity-empty').innerText(); + const match = text.match(/Watching (\d+) monitors?/i); + expect(match, `expected a "Watching N monitor(s)" count in: ${text}`).not.toBeNull(); + expect(Number(match![1])).toBe(listed); +}); diff --git a/docs/user-guide/live-activity.md b/docs/user-guide/live-activity.md index 34e9d9da..0569532a 100644 --- a/docs/user-guide/live-activity.md +++ b/docs/user-guide/live-activity.md @@ -41,7 +41,7 @@ Open the gear icon at the top of the page to configure: - **Maximum tiles**: how many tiles the grid shows at once before the rest collapse into the overflow count. - **Monitors to watch**: a per-monitor switch that keeps a monitor off this page only. It stays visible everywhere else in the app. This is separate from the hidden monitors list in {doc}`settings`, which hides a monitor from the whole app. -A monitor set to record continuously starts switched off here, and its row says so. A camera that always records is always inside an event, so it would sit on this page permanently and push out whatever is actually alarming. Switch one back on if you do want to watch it; that choice is remembered separately from the monitors you turned off yourself. +Every monitor is watched by default, including cameras set to record continuously. A camera that always records is always inside an event, but an event is not an alarm: ZoneMinder still reports such a camera as idle until motion is detected, so it appears here only when something actually happens. Turn one off with the switch if you would rather not see it on this page. A push notification for a monitor promotes it onto the page immediately, rather than waiting for the next scheduled check.