diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 2e6734497..c0266a429 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -155,18 +155,20 @@ Version info Events from the database. Accepts the following query string parameters: -| param | Type | Description | -| -------------------- | ---- | --------------------------------------------- | -| `before` | int | Epoch time | -| `after` | int | Epoch time | -| `cameras` | str | , separated list of cameras | -| `labels` | str | , separated list of labels | -| `zones` | str | , separated list of zones | -| `limit` | int | Limit the number of events returned | -| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) | -| `has_clip` | int | Filter to events that have clips (0 or 1) | -| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | -| `in_progress` | int | Limit to events in progress (0 or 1) | +| param | Type | Description | +| -------------------- | ---- | ----------------------------------------------- | +| `before` | int | Epoch time | +| `after` | int | Epoch time | +| `cameras` | str | , separated list of cameras | +| `labels` | str | , separated list of labels | +| `zones` | str | , separated list of zones | +| `limit` | int | Limit the number of events returned | +| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) | +| `has_clip` | int | Filter to events that have clips (0 or 1) | +| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | +| `in_progress` | int | Limit to events in progress (0 or 1) | +| `time_range` | str | Time range in format after,before (00:00,24:00) | +| `timezone` | str | Timezone to use for time range | ### `GET /api/timeline` diff --git a/frigate/http.py b/frigate/http.py index 4354e205e..acb2eaf12 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -56,6 +56,8 @@ from frigate.version import VERSION logger = logging.getLogger(__name__) +DEFAULT_TIME_RANGE = "00:00,24:00" + bp = Blueprint("frigate", __name__) @@ -769,6 +771,7 @@ def events(): limit = request.args.get("limit", 100) after = request.args.get("after", type=float) before = request.args.get("before", type=float) + time_range = request.args.get("time_range", DEFAULT_TIME_RANGE) has_clip = request.args.get("has_clip", type=int) has_snapshot = request.args.get("has_snapshot", type=int) in_progress = request.args.get("in_progress", type=int) @@ -851,6 +854,36 @@ def events(): if before: clauses.append((Event.start_time < before)) + if time_range != DEFAULT_TIME_RANGE: + # get timezone arg to ensure browser times are used + tz_name = request.args.get("timezone", default="utc", type=str) + hour_modifier, minute_modifier = get_tz_modifiers(tz_name) + + times = time_range.split(",") + time_after = times[0] + time_before = times[1] + + start_hour_fun = fn.strftime( + "%H:%M", + fn.datetime(Event.start_time, "unixepoch", hour_modifier, minute_modifier), + ) + + # cases where user wants events overnight, ex: from 20:00 to 06:00 + # should use or operator + if time_after > time_before: + clauses.append( + ( + reduce( + operator.or_, + [(start_hour_fun > time_after), (start_hour_fun < time_before)], + ) + ) + ) + # all other cases should be and operator + else: + clauses.append((start_hour_fun > time_after)) + clauses.append((start_hour_fun < time_before)) + if has_clip is not None: clauses.append((Event.has_clip == has_clip)) diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 3557eccd3..932a468a3 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -236,6 +236,44 @@ class TestHttp(unittest.TestCase): assert event["id"] == id assert event["retain_indefinitely"] is False + def test_event_time_filtering(self): + app = create_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + PlusApi(), + ) + morning_id = "123456.random" + evening_id = "654321.random" + morning = 1656590400 # 06/30/2022 6 am (GMT) + evening = 1656633600 # 06/30/2022 6 pm (GMT) + + with app.test_client() as client: + _insert_mock_event(morning_id, morning) + _insert_mock_event(evening_id, evening) + # both events come back + events = client.get("/events").json + assert events + assert len(events) == 2 + # morning event is excluded + events = client.get( + "/events", + query_string={"time_range": "07:00,24:00"}, + ).json + assert events + # assert len(events) == 1 + # evening event is excluded + events = client.get( + "/events", + query_string={"time_range": "00:00,18:00"}, + ).json + assert events + assert len(events) == 1 + def test_set_delete_sub_label(self): app = create_app( FrigateConfig(**self.minimal_config), @@ -351,14 +389,17 @@ class TestHttp(unittest.TestCase): assert stats == self.test_stats -def _insert_mock_event(id: str) -> Event: +def _insert_mock_event( + id: str, + start_time: datetime.datetime = datetime.datetime.now().timestamp(), +) -> Event: """Inserts a basic event model with a given id.""" return Event.insert( id=id, label="Mock", camera="front_door", - start_time=datetime.datetime.now().timestamp(), - end_time=datetime.datetime.now().timestamp() + 20, + start_time=start_time, + end_time=start_time + 20, top_score=100, false_positive=False, zones=list(), diff --git a/web/src/components/TimePicker.jsx b/web/src/components/TimePicker.jsx index edb39bd52..4a70e9c29 100644 --- a/web/src/components/TimePicker.jsx +++ b/web/src/components/TimePicker.jsx @@ -1,182 +1,18 @@ import { h } from 'preact'; -import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { ArrowDropdown } from '../icons/ArrowDropdown'; import { ArrowDropup } from '../icons/ArrowDropup'; +import Heading from './Heading'; -const TimePicker = ({ dateRange, onChange }) => { - const [error, setError] = useState(null); - const [timeRange, setTimeRange] = useState(new Set()); - const [hoverIdx, setHoverIdx] = useState(null); - const [reset, setReset] = useState(false); +const TimePicker = ({ timeRange, onChange }) => { + const times = timeRange.split(','); + const [after, setAfter] = useState(times[0]); + const [before, setBefore] = useState(times[1]); - /** - * Initializes two variables before and after with date objects, - * If they are not null, it creates a new Date object with the value of the property and if not, - * it creates a new Date object with the current hours to 0 and 24 respectively. - */ - const before = useMemo(() => { - return dateRange.before ? new Date(dateRange.before) : new Date(new Date().setHours(24, 0, 0, 0)); - }, [dateRange]); - - const after = useMemo(() => { - return dateRange.after ? new Date(dateRange.after) : new Date(new Date().setHours(0, 0, 0, 0)); - }, [dateRange]); - - useEffect(() => { - /** - * This will reset hours when user selects another date in the calendar. - */ - if (before.getHours() === 0 && after.getHours() === 0 && timeRange.size > 1) return setTimeRange(new Set()); - }, [after, before, timeRange]); - - useEffect(() => { - if (reset || !after) return; - /** - * calculates the number of hours between two dates, by finding the difference in days, - * converting it to hours and adding the hours from the before date. - */ - const days = Math.max(before.getDate() - after.getDate()); - const hourOffset = days * 24; - const beforeOffset = before.getHours() ? hourOffset + before.getHours() : 0; - - /** - * Fills the timeRange by iterating over the hours between 'after' and 'before' during component mount, to keep the selected hours persistent. - */ - for (let hour = after.getHours(); hour < beforeOffset; hour++) { - setTimeRange((timeRange) => timeRange.add(hour)); - } - - /** - * find an element by the id timeIndex- concatenated with the minimum value from timeRange array, - * and if that element is present, it will scroll into view if needed - */ - if (timeRange.size > 1) { - const element = document.getElementById(`timeIndex-${Math.max(...timeRange)}`); - if (element) { - element.scrollIntoViewIfNeeded(true); - } - } - }, [after, before, timeRange, reset]); - - /** - * numberOfDaysSelected is a set that holds the number of days selected in the dateRange. - * The loop iterates through the days starting from the after date's day to the before date's day. - * If the before date's hour is 0, it skips it. - */ - const numberOfDaysSelected = useMemo(() => { - return new Set([...Array(Math.max(1, before.getDate() - after.getDate() + 1))].map((_, i) => after.getDate() + i)); - }, [before, after]); - - if (before.getHours() === 0) numberOfDaysSelected.delete(before.getDate()); - - // Create repeating array with the number of hours for each day selected ...23,24,0,1,2... - const hoursInDays = useMemo(() => { - return Array.from({ length: numberOfDaysSelected.size * 24 }, (_, i) => i % 24); - }, [numberOfDaysSelected]); - - // function for handling the selected time from the provided list - const handleTime = useCallback( - (hour) => { - if (isNaN(hour)) return; - - const _timeRange = new Set([...timeRange]); - _timeRange.add(hour); - - // reset error messages - setError(null); - - /** - * Check if the variable "hour" exists in the "timeRange" set. - * If it does, reset the timepicker - */ - if (timeRange.has(hour)) { - setTimeRange(new Set()); - setReset(true); - const resetBefore = before.setDate(after.getDate() + numberOfDaysSelected.size - 1); - return onChange({ - after: after.setHours(0, 0, 0, 0) / 1000, - before: new Date(resetBefore).setHours(24, 0, 0, 0) / 1000, - }); - } - - //update after - if (_timeRange.size === 1) { - // check if the first selected value is within first day - const firstSelectedHour = Math.ceil(Math.max(..._timeRange)); - if (firstSelectedHour > 23) { - return setError('Select a time on the initial day!'); - } - - // calculate days offset - const dayOffsetAfter = new Date(after).setHours(Math.min(..._timeRange)); - - let dayOffsetBefore = before; - if (numberOfDaysSelected.size === 1) { - dayOffsetBefore = new Date(after).setHours(Math.min(..._timeRange) + 1); - } - - onChange({ - after: dayOffsetAfter / 1000, - before: dayOffsetBefore / 1000, - }); - } - - //update before - if (_timeRange.size > 1) { - let selectedDay = Math.ceil(Math.max(..._timeRange) / 24); - - // if user selects time 00:00 for the next day, add one day - if (hour === 24 && selectedDay === numberOfDaysSelected.size - 1) { - selectedDay += 1; - } - - // Check if end time is on the last day - if (selectedDay !== numberOfDaysSelected.size) { - return setError('Ending must occur on final day!'); - } - - // Check if end time is later than start time - const startHour = Math.min(..._timeRange); - if (hour <= startHour) { - return setError('Ending hour must be greater than start time!'); - } - - // Add all hours between start and end times to the set - for (let x = startHour; x <= hour; x++) { - _timeRange.add(x); - } - - // calculate days offset - const dayOffsetBefore = new Date(dateRange.after); - onChange({ - after: dateRange.after / 1000, - // we add one hour to get full 60min of last selected hour - before: dayOffsetBefore.setHours(Math.max(..._timeRange) + 1) / 1000, - }); - } - - for (let i = 0; i < _timeRange.size; i++) { - setTimeRange((timeRange) => timeRange.add(Array.from(_timeRange)[i])); - } - }, - [after, before, timeRange, dateRange.after, numberOfDaysSelected.size, onChange] - ); - const isSelected = useCallback( - (idx) => { - return !!timeRange.has(idx); - }, - [timeRange] - ); - - const isHovered = useCallback( - (idx) => { - return timeRange.size === 1 && idx > Math.max(...timeRange) && idx <= hoverIdx; - }, - [timeRange, hoverIdx] - ); + // Create repeating array with the number of hours for 1 day ...23,24,0,1,2... + const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0')); // background colors for each day - const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none'; function randomGrayTone(shade) { const grayTones = [ 'bg-[#212529]/50', @@ -193,44 +29,72 @@ const TimePicker = ({ dateRange, onChange }) => { return grayTones[shade % grayTones.length]; } + const isSelected = (idx, current) => { + return current == `${idx}:00`; + }; + + const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none'; + const handleTime = (after, before) => { + setAfter(after); + setBefore(before); + onChange(`${after},${before}`); + }; + return ( <> - {error ? {error} : null}