mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 19:30:33 +03:00
Compare commits
10
Commits
analytics
...
misc-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92ad23116 | ||
|
|
72c97f2b7d | ||
|
|
fac65fdca2 | ||
|
|
309a4e5aa8 | ||
|
|
f246db3f4e | ||
|
|
9fca3f276d | ||
|
|
f68f360386 | ||
|
|
5764eaddaa | ||
|
|
a0b8d40a66 | ||
|
|
9b0aa75312 |
@@ -55,6 +55,9 @@ updates:
|
|||||||
# Needs Python 3.12 and numpy 2
|
# Needs Python 3.12 and numpy 2
|
||||||
- dependency-name: "librosa"
|
- dependency-name: "librosa"
|
||||||
versions: [">=1.0"]
|
versions: [">=1.0"]
|
||||||
|
# pandas 3.1 requires numpy 2
|
||||||
|
- dependency-name: "pandas"
|
||||||
|
versions: [">=3.1"]
|
||||||
# Needs Python 3.12
|
# Needs Python 3.12
|
||||||
- dependency-name: "scipy"
|
- dependency-name: "scipy"
|
||||||
versions: [">=1.18"]
|
versions: [">=1.18"]
|
||||||
|
|||||||
@@ -578,6 +578,10 @@ notifications:
|
|||||||
email: "admin@example.com"
|
email: "admin@example.com"
|
||||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||||
cooldown: 0
|
cooldown: 0
|
||||||
|
# Optional: Delivery of notifications sent while an alert is active (default: shown below)
|
||||||
|
# Options are immediate (deliver right away or drop) and reliable (retry for up to 30 seconds)
|
||||||
|
# NOTE: reliable can deliver late notifications that still link to the live view
|
||||||
|
ttl: immediate
|
||||||
|
|
||||||
# Optional: Record configuration
|
# Optional: Record configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Notifications will be prevented if either:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
1. Navigate to <NavPath path="Settings > Notifications > Notifications" />.
|
1. Navigate to <NavPath path="Settings > Notifications" />.
|
||||||
- Set **Email** to your email address
|
- Set **Email** to your email address
|
||||||
- Enable notifications for the desired cameras
|
- Enable notifications for the desired cameras
|
||||||
|
|
||||||
@@ -108,6 +108,29 @@ Currently, only Chrome supports images in notifications. Safari and Firefox will
|
|||||||
|
|
||||||
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
|
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
|
||||||
|
|
||||||
|
### Notification TTL
|
||||||
|
|
||||||
|
By default, Frigate sends notifications for an active alert with a `ttl` of `immediate`, so the push service either delivers them immediately or drops them. If your device is briefly unreachable (asleep, switching from Wi-Fi to cellular, or on a weak signal), those notifications are lost and you only receive the notification sent when the alert ends, which is always kept for up to an hour.
|
||||||
|
|
||||||
|
Setting `ttl` to `reliable` tells the push service to keep retrying these notifications for up to 30 seconds. This makes delivery more reliable, but a notification delivered late will still say an object is detected, even if the object has already left.
|
||||||
|
|
||||||
|
<ConfigTabs>
|
||||||
|
<TabItem value="ui">
|
||||||
|
|
||||||
|
1. Navigate to <NavPath path="Settings > Notifications" />.
|
||||||
|
- Expand **Advanced Settings** and set **Notification TTL** to **Reliable**
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="yaml">
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notifications:
|
||||||
|
ttl: reliable # retry an undelivered notification for up to 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</ConfigTabs>
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
|
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
|
||||||
|
|||||||
Vendored
+2
@@ -6371,6 +6371,7 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
minimum: 0
|
||||||
description: Padding to apply to the vod.
|
description: Padding to apply to the vod.
|
||||||
default: 0
|
default: 0
|
||||||
title: Padding
|
title: Padding
|
||||||
@@ -7576,6 +7577,7 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
minimum: 1
|
||||||
default: 30
|
default: 30
|
||||||
title: Scale
|
title: Scale
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -966,6 +966,10 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
try:
|
try:
|
||||||
db_user: User = User.get_by_id(user)
|
db_user: User = User.get_by_id(user)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
# Hash anyway so response timing doesn't reveal which usernames exist
|
||||||
|
hash_password(
|
||||||
|
password, iterations=request.app.frigate_config.auth.hash_iterations
|
||||||
|
)
|
||||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||||
failed_logins.record(user, time.time())
|
failed_logins.record(user, time.time())
|
||||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from pydantic.json_schema import SkipJsonSchema
|
from pydantic.json_schema import SkipJsonSchema
|
||||||
|
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel):
|
|||||||
cameras: str = "all"
|
cameras: str = "all"
|
||||||
before: float | SkipJsonSchema[None] = None
|
before: float | SkipJsonSchema[None] = None
|
||||||
after: float | SkipJsonSchema[None] = None
|
after: float | SkipJsonSchema[None] = None
|
||||||
scale: int = 30
|
scale: int = Field(default=30, ge=1)
|
||||||
|
|
||||||
|
|
||||||
class RecordingsDeleteQueryParams(BaseModel):
|
class RecordingsDeleteQueryParams(BaseModel):
|
||||||
|
|||||||
@@ -291,12 +291,12 @@ def events(
|
|||||||
clauses.append(
|
clauses.append(
|
||||||
reduce(
|
reduce(
|
||||||
operator.or_,
|
operator.or_,
|
||||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# all other cases should be and operator
|
# all other cases should be and operator
|
||||||
else:
|
else:
|
||||||
clauses.append(start_hour_fun > time_after)
|
clauses.append(start_hour_fun >= time_after)
|
||||||
clauses.append(start_hour_fun < time_before)
|
clauses.append(start_hour_fun < time_before)
|
||||||
|
|
||||||
if has_clip is not None:
|
if has_clip is not None:
|
||||||
@@ -749,12 +749,12 @@ def events_search(
|
|||||||
event_filters.append(
|
event_filters.append(
|
||||||
reduce(
|
reduce(
|
||||||
operator.or_,
|
operator.or_,
|
||||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# all other cases should be and operator
|
# all other cases should be and operator
|
||||||
else:
|
else:
|
||||||
event_filters.append(start_hour_fun > time_after)
|
event_filters.append(start_hour_fun >= time_after)
|
||||||
event_filters.append(start_hour_fun < time_before)
|
event_filters.append(start_hour_fun < time_before)
|
||||||
|
|
||||||
# Perform semantic search
|
# Perform semantic search
|
||||||
|
|||||||
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
|
|||||||
files_in_use = _get_files_in_use()
|
files_in_use = _get_files_in_use()
|
||||||
|
|
||||||
for export in exports:
|
for export in exports:
|
||||||
if export.video_path.split("/")[-1] in files_in_use:
|
if export.in_progress or export.video_path.split("/")[-1] in files_in_use:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ async def vod_hour(
|
|||||||
async def vod_event(
|
async def vod_event(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
padding: int = Query(0, description="Padding to apply to the vod."),
|
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
@@ -1521,6 +1521,7 @@ async def preview_gif(
|
|||||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||||
)
|
)
|
||||||
.where(Previews.camera == camera_name)
|
.where(Previews.camera == camera_name)
|
||||||
|
.order_by(Previews.start_time.asc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
)
|
)
|
||||||
@@ -1530,7 +1531,8 @@ async def preview_gif(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
diff = start_ts - preview.start_time
|
clip_start = max(start_ts, preview.start_time)
|
||||||
|
diff = clip_start - preview.start_time
|
||||||
minutes = int(diff / 60)
|
minutes = int(diff / 60)
|
||||||
seconds = int(diff % 60)
|
seconds = int(diff % 60)
|
||||||
config: FrigateConfig = request.app.frigate_config
|
config: FrigateConfig = request.app.frigate_config
|
||||||
@@ -1542,7 +1544,7 @@ async def preview_gif(
|
|||||||
"-ss",
|
"-ss",
|
||||||
f"00:{minutes}:{seconds}",
|
f"00:{minutes}:{seconds}",
|
||||||
"-t",
|
"-t",
|
||||||
f"{end_ts - start_ts}",
|
f"{end_ts - clip_start}",
|
||||||
"-i",
|
"-i",
|
||||||
preview.path,
|
preview.path,
|
||||||
"-r",
|
"-r",
|
||||||
@@ -1709,6 +1711,7 @@ async def preview_mp4(
|
|||||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||||
)
|
)
|
||||||
.where(Previews.camera == camera_name)
|
.where(Previews.camera == camera_name)
|
||||||
|
.order_by(Previews.start_time.asc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
)
|
)
|
||||||
@@ -1721,7 +1724,8 @@ async def preview_mp4(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
diff = start_ts - preview.start_time
|
clip_start = max(start_ts, preview.start_time)
|
||||||
|
diff = clip_start - preview.start_time
|
||||||
minutes = int(diff / 60)
|
minutes = int(diff / 60)
|
||||||
seconds = int(diff % 60)
|
seconds = int(diff % 60)
|
||||||
config: FrigateConfig = request.app.frigate_config
|
config: FrigateConfig = request.app.frigate_config
|
||||||
@@ -1734,7 +1738,7 @@ async def preview_mp4(
|
|||||||
"-ss",
|
"-ss",
|
||||||
f"00:{minutes}:{seconds}",
|
f"00:{minutes}:{seconds}",
|
||||||
"-t",
|
"-t",
|
||||||
f"{end_ts - start_ts}",
|
f"{end_ts - clip_start}",
|
||||||
"-i",
|
"-i",
|
||||||
preview.path,
|
preview.path,
|
||||||
"-r",
|
"-r",
|
||||||
|
|||||||
@@ -163,8 +163,7 @@ async def review(
|
|||||||
|
|
||||||
# Apply ordering and limit
|
# Apply ordering and limit
|
||||||
review_query = (
|
review_query = (
|
||||||
review_query.order_by(ReviewSegment.severity.asc())
|
review_query.order_by(ReviewSegment.start_time.desc())
|
||||||
.order_by(ReviewSegment.start_time.desc())
|
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.dicts()
|
.dicts()
|
||||||
.iterator()
|
.iterator()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from frigate.comms.base_communicator import Communicator
|
|||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.auth import AuthConfig
|
from frigate.config.auth import AuthConfig
|
||||||
|
from frigate.config.camera.notification import NOTIFICATION_TTL_SECONDS
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
@@ -623,7 +624,7 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
# if event is ongoing open to live view otherwise open to recordings view
|
# if event is ongoing open to live view otherwise open to recordings view
|
||||||
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
|
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
|
||||||
ttl = 3600 if ended else 0
|
ttl = 3600 if ended else NOTIFICATION_TTL_SECONDS[self.config.notifications.ttl]
|
||||||
|
|
||||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from frigate.util.env import EnvString
|
from frigate.util.env import EnvString
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
|
|
||||||
__all__ = ["NotificationConfig"]
|
__all__ = ["NotificationConfig", "NotificationTTLEnum", "NOTIFICATION_TTL_SECONDS"]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTTLEnum(str, Enum):
|
||||||
|
immediate = "immediate"
|
||||||
|
reliable = "reliable"
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATION_TTL_SECONDS = {
|
||||||
|
NotificationTTLEnum.immediate: 0,
|
||||||
|
NotificationTTLEnum.reliable: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class NotificationConfig(FrigateBaseModel):
|
class NotificationConfig(FrigateBaseModel):
|
||||||
@@ -24,6 +37,11 @@ class NotificationConfig(FrigateBaseModel):
|
|||||||
title="Cooldown period",
|
title="Cooldown period",
|
||||||
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
|
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
|
||||||
)
|
)
|
||||||
|
ttl: NotificationTTLEnum = Field(
|
||||||
|
default=NotificationTTLEnum.immediate,
|
||||||
|
title="Notification TTL",
|
||||||
|
description="How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour.",
|
||||||
|
)
|
||||||
enabled_in_config: bool | None = Field(
|
enabled_in_config: bool | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="Original notifications state",
|
title="Original notifications state",
|
||||||
|
|||||||
@@ -625,7 +625,12 @@ class OnvifController:
|
|||||||
|
|
||||||
move_request.Speed = move_speed
|
move_request.Speed = move_speed
|
||||||
|
|
||||||
await cam["ptz"].RelativeMove(move_request)
|
try:
|
||||||
|
await cam["ptz"].RelativeMove(move_request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Onvif sending relative move request to {camera_name} failed: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# reset after the move request
|
# reset after the move request
|
||||||
move_request.Translation.PanTilt.x = 0
|
move_request.Translation.PanTilt.x = 0
|
||||||
|
|||||||
@@ -1470,6 +1470,9 @@ class RecordingExporter(threading.Thread):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._run_export(video_path, thumb_path)
|
self._run_export(video_path, thumb_path)
|
||||||
|
except Exception:
|
||||||
|
self._discard_failed_export(video_path, thumb_path)
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
# staged runs hold a full copy of the export, so they must not
|
# staged runs hold a full copy of the export, so they must not
|
||||||
# survive a failure the way the small chapter file could
|
# survive a failure the way the small chapter file could
|
||||||
@@ -1479,7 +1482,8 @@ class RecordingExporter(threading.Thread):
|
|||||||
"""Drop the partial output and the row that promised it."""
|
"""Drop the partial output and the row that promised it."""
|
||||||
Path(video_path).unlink(missing_ok=True)
|
Path(video_path).unlink(missing_ok=True)
|
||||||
Export.delete().where(Export.id == self.export_id).execute()
|
Export.delete().where(Export.id == self.export_id).execute()
|
||||||
Path(thumb_path).unlink(missing_ok=True)
|
if thumb_path:
|
||||||
|
Path(thumb_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
def _run_export(self, video_path: str, thumb_path: str) -> None:
|
def _run_export(self, video_path: str, thumb_path: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -1498,6 +1502,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
else:
|
else:
|
||||||
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
self._discard_failed_export(video_path, thumb_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# When neither custom ffmpeg arg is set the default path uses
|
# When neither custom ffmpeg arg is set the default path uses
|
||||||
|
|||||||
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_event_time_filtering(self):
|
def test_event_time_filtering(self):
|
||||||
morning_id = "123456.random"
|
morning_id = "123456.random"
|
||||||
evening_id = "654321.random"
|
evening_id = "654321.random"
|
||||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
morning = 1656568800 # 06/30/2022 6 am (GMT)
|
||||||
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
evening = 1656612000 # 06/30/2022 6 pm (GMT)
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(morning_id, morning)
|
super().insert_mock_event(morning_id, morning)
|
||||||
@@ -417,6 +417,12 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
).json()
|
).json()
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
|
# an event in the first minute of the range is included
|
||||||
|
events = client.get(
|
||||||
|
"/events",
|
||||||
|
params={"time_range": "06:00,24:00"},
|
||||||
|
).json()
|
||||||
|
assert len(events) == 2
|
||||||
|
|
||||||
def test_set_delete_sub_label(self):
|
def test_set_delete_sub_label(self):
|
||||||
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
||||||
|
|||||||
@@ -427,6 +427,30 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert Export.get(Export.id == "front_door_running").video_path == video
|
assert Export.get(Export.id == "front_door_running").video_path == video
|
||||||
|
|
||||||
|
def test_delete_export_rejected_while_in_progress(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
video = os.path.join(tmpdir, "front_door_abc123.mp4")
|
||||||
|
with open(video, "wb") as handle:
|
||||||
|
handle.write(b"video")
|
||||||
|
|
||||||
|
Export.create(
|
||||||
|
id="front_door_running",
|
||||||
|
camera="front_door",
|
||||||
|
name="front door export",
|
||||||
|
date=100,
|
||||||
|
video_path=video,
|
||||||
|
thumb_path=os.path.join(tmpdir, "t.webp"),
|
||||||
|
in_progress=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/exports/delete", json={"ids": ["front_door_running"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert os.path.exists(video)
|
||||||
|
|
||||||
def test_rename_export_missing_file_leaves_the_row_alone(self):
|
def test_rename_export_missing_file_leaves_the_row_alone(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
|
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
|
||||||
|
|||||||
@@ -584,6 +584,10 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
{"start_time": 1160, "end_time": 1300},
|
{"start_time": 1160, "end_time": 1300},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# a non-positive scale would never advance the gap scan
|
||||||
|
response = client.get("/recordings/unavailable", params={"scale": 0})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_recordings_summary_day_attribution_by_start_time(self):
|
def test_recordings_summary_day_attribution_by_start_time(self):
|
||||||
"""
|
"""
|
||||||
A recording spanning midnight marks only its start day.
|
A recording spanning midnight marks only its start day.
|
||||||
|
|||||||
@@ -195,6 +195,15 @@ class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_failed_move_does_not_raise(self) -> None:
|
||||||
|
# an exception here ends the autotracker's move queue consumer for good
|
||||||
|
controller = _make_move_controller(autotracking_enabled=True)
|
||||||
|
controller.cams[CAMERA]["ptz"].RelativeMove.side_effect = TimeoutError
|
||||||
|
|
||||||
|
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||||
|
|
||||||
|
self.assertFalse(controller.cams[CAMERA]["active"])
|
||||||
|
|
||||||
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
||||||
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
||||||
# autotracking path keeps its motion estimation timestamps
|
# autotracking path keeps its motion estimation timestamps
|
||||||
|
|||||||
@@ -858,6 +858,10 @@
|
|||||||
"label": "Cooldown period",
|
"label": "Cooldown period",
|
||||||
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
||||||
},
|
},
|
||||||
|
"ttl": {
|
||||||
|
"label": "Notification TTL",
|
||||||
|
"description": "How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour."
|
||||||
|
},
|
||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "Original notifications state",
|
"label": "Original notifications state",
|
||||||
"description": "Indicates whether notifications were enabled in the original static configuration."
|
"description": "Indicates whether notifications were enabled in the original static configuration."
|
||||||
|
|||||||
@@ -154,6 +154,10 @@
|
|||||||
"label": "Cooldown period",
|
"label": "Cooldown period",
|
||||||
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
||||||
},
|
},
|
||||||
|
"ttl": {
|
||||||
|
"label": "Notification TTL",
|
||||||
|
"description": "How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour."
|
||||||
|
},
|
||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "Original notifications state",
|
"label": "Original notifications state",
|
||||||
"description": "Indicates whether notifications were enabled in the original static configuration."
|
"description": "Indicates whether notifications were enabled in the original static configuration."
|
||||||
|
|||||||
@@ -1934,6 +1934,10 @@
|
|||||||
"saved": "Saved"
|
"saved": "Saved"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notificationTtl": {
|
||||||
|
"immediate": "Immediate",
|
||||||
|
"reliable": "Reliable"
|
||||||
|
},
|
||||||
"retainMode": {
|
"retainMode": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"motion": "Motion",
|
"motion": "Motion",
|
||||||
@@ -2051,7 +2055,8 @@
|
|||||||
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
|
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera.",
|
||||||
|
"ttlDelay": "Notifications can arrive up to 30 seconds late and still link to the live view after the review item has ended."
|
||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const notifications: SectionConfigOverrides = {
|
|||||||
fieldOrder: ["enabled", "email"],
|
fieldOrder: ["enabled", "email"],
|
||||||
fieldGroups: {},
|
fieldGroups: {},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config"],
|
||||||
advancedFields: [],
|
advancedFields: ["ttl"],
|
||||||
fieldMessages: [
|
fieldMessages: [
|
||||||
{
|
{
|
||||||
key: "profile-base-notifications-disabled",
|
key: "profile-base-notifications-disabled",
|
||||||
@@ -24,6 +24,14 @@ const notifications: SectionConfigOverrides = {
|
|||||||
camera.notifications.enabled_in_config,
|
camera.notifications.enabled_in_config,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "notifications-ttl-delay",
|
||||||
|
field: "ttl",
|
||||||
|
messageKey: "configMessages.notifications.ttlDelay",
|
||||||
|
severity: "warning",
|
||||||
|
position: "after",
|
||||||
|
condition: (ctx) => ctx.formData?.ttl === "reliable",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
@@ -31,12 +39,13 @@ const notifications: SectionConfigOverrides = {
|
|||||||
"ui:before": { render: "NotificationsSettingsExtras" },
|
"ui:before": { render: "NotificationsSettingsExtras" },
|
||||||
enabled: { "ui:widget": "hidden" },
|
enabled: { "ui:widget": "hidden" },
|
||||||
email: { "ui:widget": "hidden" },
|
email: { "ui:widget": "hidden" },
|
||||||
|
ttl: { "ui:options": { enumI18nPrefix: "notificationTtl" } },
|
||||||
cooldown: { "ui:widget": "hidden" },
|
cooldown: { "ui:widget": "hidden" },
|
||||||
enabled_in_config: { "ui:widget": "hidden" },
|
enabled_in_config: { "ui:widget": "hidden" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
hiddenFields: ["enabled_in_config", "email"],
|
hiddenFields: ["enabled_in_config", "email", "ttl"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export default function NotificationsSettingsExtras({
|
|||||||
set(nextNotifications, "enabled", desiredEnabled);
|
set(nextNotifications, "enabled", desiredEnabled);
|
||||||
const sanitizedNotifications = sanitizeSectionData(
|
const sanitizedNotifications = sanitizeSectionData(
|
||||||
nextNotifications as ConfigSectionData,
|
nextNotifications as ConfigSectionData,
|
||||||
["enabled_in_config", "email"],
|
["enabled_in_config", "email", "ttl"],
|
||||||
);
|
);
|
||||||
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
|
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
|
||||||
pendingCameraOverridesRef.current.add(camera.name);
|
pendingCameraOverridesRef.current.add(camera.name);
|
||||||
@@ -415,10 +415,19 @@ export default function NotificationsSettingsExtras({
|
|||||||
pushSubscription.unsubscribe();
|
pushSubscription.unsubscribe();
|
||||||
workerRegistration.unregister();
|
workerRegistration.unregister();
|
||||||
setRegistration(null);
|
setRegistration(null);
|
||||||
|
removeMessage("notification_settings", "registration");
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t("notification.toast.error.registerFailed"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
workerRegistration.unregister();
|
||||||
|
setRegistration(null);
|
||||||
|
removeMessage("notification_settings", "registration");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[addMessage, publicKey, t],
|
[addMessage, removeMessage, publicKey, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -490,7 +499,7 @@ export default function NotificationsSettingsExtras({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto md:order-none">
|
||||||
<div className={cn("w-full max-w-5xl space-y-6")}>
|
<div className={cn("w-full max-w-5xl space-y-6")}>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
|
|||||||
@@ -307,6 +307,9 @@ export function KnownPlatesField(props: FieldProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAddEntry = useCallback(() => {
|
const handleAddEntry = useCallback(() => {
|
||||||
|
if ("" in data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const next = { ...data, "": [""] };
|
const next = { ...data, "": [""] };
|
||||||
onChange(next, fieldPath);
|
onChange(next, fieldPath);
|
||||||
}, [data, fieldPath, onChange]);
|
}, [data, fieldPath, onChange]);
|
||||||
|
|||||||
@@ -690,36 +690,8 @@ export function ExportContent({
|
|||||||
(option: ExportOption) => {
|
(option: ExportOption) => {
|
||||||
setSelectedOption(option);
|
setSelectedOption(option);
|
||||||
|
|
||||||
const now = new Date(latestTime * 1000);
|
const hours = Number(option);
|
||||||
let start: number;
|
const start = latestTime - (Number.isNaN(hours) ? 1 : hours) * 3600;
|
||||||
|
|
||||||
switch (option) {
|
|
||||||
case "1":
|
|
||||||
now.setHours(now.getHours() - 1);
|
|
||||||
start = now.getTime() / 1000;
|
|
||||||
break;
|
|
||||||
case "4":
|
|
||||||
now.setHours(now.getHours() - 4);
|
|
||||||
start = now.getTime() / 1000;
|
|
||||||
break;
|
|
||||||
case "8":
|
|
||||||
now.setHours(now.getHours() - 8);
|
|
||||||
start = now.getTime() / 1000;
|
|
||||||
break;
|
|
||||||
case "12":
|
|
||||||
now.setHours(now.getHours() - 12);
|
|
||||||
start = now.getTime() / 1000;
|
|
||||||
break;
|
|
||||||
case "24":
|
|
||||||
now.setHours(now.getHours() - 24);
|
|
||||||
start = now.getTime() / 1000;
|
|
||||||
break;
|
|
||||||
case "custom":
|
|
||||||
start = latestTime - 3600;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
start = latestTime - 3600;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRange({
|
setRange({
|
||||||
before: latestTime,
|
before: latestTime,
|
||||||
|
|||||||
@@ -147,7 +147,18 @@ export default function DetailStream({
|
|||||||
// away instead of waiting for a future effectiveTime change.
|
// away instead of waiting for a future effectiveTime change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reviewItems || reviewItems.length === 0) return;
|
if (!reviewItems || reviewItems.length === 0) return;
|
||||||
if (activeReviewId) return;
|
|
||||||
|
// an id left over from another camera's items counts as unset
|
||||||
|
if (
|
||||||
|
activeReviewId &&
|
||||||
|
reviewItems.some(
|
||||||
|
(r) =>
|
||||||
|
`review-${r.id ?? r.start_time ?? Math.floor(r.start_time ?? 0)}` ===
|
||||||
|
activeReviewId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let target: ReviewSegment | undefined;
|
let target: ReviewSegment | undefined;
|
||||||
let closest: { r: ReviewSegment; diff: number } | undefined;
|
let closest: { r: ReviewSegment; diff: number } | undefined;
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ export function createRecordingReviewUrl(
|
|||||||
pathname: string,
|
pathname: string,
|
||||||
state: RecordingReviewLinkState,
|
state: RecordingReviewLinkState,
|
||||||
): string {
|
): string {
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(pathname.replace(/^\//, ""), baseUrl);
|
||||||
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
||||||
url.searchParams.set(
|
url.searchParams.set(
|
||||||
RECORDING_REVIEW_LINK_PARAM,
|
RECORDING_REVIEW_LINK_PARAM,
|
||||||
`${state.camera}_${Math.floor(state.timestamp)}`,
|
`${state.camera}_${Math.floor(state.timestamp)}`,
|
||||||
|
|||||||
@@ -575,25 +575,19 @@ export default function DraggableGridLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allGroupsStreamingSettings) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialAudioStates: AudioState = {};
|
const initialAudioStates: AudioState = {};
|
||||||
const initialVolumeStates: VolumeState = {};
|
const initialVolumeStates: VolumeState = {};
|
||||||
|
|
||||||
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
|
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
|
||||||
if (groupSettings) {
|
([camera, cameraSettings]) => {
|
||||||
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
|
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
},
|
||||||
});
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setAudioStates(initialAudioStates);
|
setAudioStates(initialAudioStates);
|
||||||
setVolumeStates(initialVolumeStates);
|
setVolumeStates(initialVolumeStates);
|
||||||
}, [allGroupsStreamingSettings]);
|
}, [currentGroupStreamingSettings, cameraGroup]);
|
||||||
|
|
||||||
const toggleAudio = (cameraName: string) => {
|
const toggleAudio = (cameraName: string) => {
|
||||||
setAudioStates((prev) => ({
|
setAudioStates((prev) => ({
|
||||||
@@ -800,7 +794,7 @@ export default function DraggableGridLayout({
|
|||||||
toggleAudio={() => toggleAudio(camera.name)}
|
toggleAudio={() => toggleAudio(camera.name)}
|
||||||
statsState={statsStates[camera.name]}
|
statsState={statsStates[camera.name]}
|
||||||
toggleStats={() => toggleStats(camera.name)}
|
toggleStats={() => toggleStats(camera.name)}
|
||||||
volumeState={volumeStates[camera.name]}
|
volumeState={volumeStates[camera.name] ?? 1}
|
||||||
setVolumeState={(value) =>
|
setVolumeState={(value) =>
|
||||||
setVolumeStates((prev) => ({
|
setVolumeStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -845,7 +839,7 @@ export default function DraggableGridLayout({
|
|||||||
onError={(e) => handleError(camera.name, e)}
|
onError={(e) => handleError(camera.name, e)}
|
||||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||||
playAudio={audioStates[camera.name]}
|
playAudio={audioStates[camera.name]}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name] ?? 1}
|
||||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
||||||
/>
|
/>
|
||||||
</GridLiveContextMenu>
|
</GridLiveContextMenu>
|
||||||
|
|||||||
@@ -511,7 +511,14 @@ export default function LiveCameraView({
|
|||||||
}
|
}
|
||||||
}, [webRTCVerdictPending]);
|
}, [webRTCVerdictPending]);
|
||||||
|
|
||||||
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
|
const toggleMic = () => {
|
||||||
|
setMic(!mic);
|
||||||
|
if (!mic && !audio) {
|
||||||
|
setAudio(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyboardListener(["m", "t", "Escape"], (key, modifiers) => {
|
||||||
if (!modifiers.down) {
|
if (!modifiers.down) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -525,7 +532,7 @@ export default function LiveCameraView({
|
|||||||
break;
|
break;
|
||||||
case "t":
|
case "t":
|
||||||
if (supports2WayTalk && talkAvailable) {
|
if (supports2WayTalk && talkAvailable) {
|
||||||
setMic(!mic);
|
toggleMic();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -792,12 +799,7 @@ export default function LiveCameraView({
|
|||||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={toggleMic}
|
||||||
setMic(!mic);
|
|
||||||
if (!mic && !audio) {
|
|
||||||
setAudio(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!cameraEnabled || debug || !talkAvailable}
|
disabled={!cameraEnabled || debug || !talkAvailable}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -326,25 +326,19 @@ export default function LiveDashboardView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allGroupsStreamingSettings) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialAudioStates: AudioState = {};
|
const initialAudioStates: AudioState = {};
|
||||||
const initialVolumeStates: VolumeState = {};
|
const initialVolumeStates: VolumeState = {};
|
||||||
|
|
||||||
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
|
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
|
||||||
if (groupSettings) {
|
([camera, cameraSettings]) => {
|
||||||
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
|
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
},
|
||||||
});
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setAudioStates(initialAudioStates);
|
setAudioStates(initialAudioStates);
|
||||||
setVolumeStates(initialVolumeStates);
|
setVolumeStates(initialVolumeStates);
|
||||||
}, [allGroupsStreamingSettings]);
|
}, [currentGroupStreamingSettings, cameraGroup]);
|
||||||
|
|
||||||
const toggleAudio = (cameraName: string): void => {
|
const toggleAudio = (cameraName: string): void => {
|
||||||
setAudioStates((prev) => ({
|
setAudioStates((prev) => ({
|
||||||
@@ -635,7 +629,7 @@ export default function LiveDashboardView({
|
|||||||
resetPreferredLiveMode(camera.name)
|
resetPreferredLiveMode(camera.name)
|
||||||
}
|
}
|
||||||
playAudio={audioStates[camera.name] ?? false}
|
playAudio={audioStates[camera.name] ?? false}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name] ?? 1}
|
||||||
/>
|
/>
|
||||||
</LiveContextMenu>
|
</LiveContextMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,17 +86,21 @@ export default function MediaSyncSettingsView() {
|
|||||||
position: "top-center",
|
position: "top-center",
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
} else if (response.status === 409) {
|
}
|
||||||
toast.error(t("maintenance.sync.alreadyRunning"), {
|
} catch (error) {
|
||||||
|
const alreadyRunning =
|
||||||
|
axios.isAxiosError(error) && error.response?.status === 409;
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
alreadyRunning
|
||||||
|
? "maintenance.sync.alreadyRunning"
|
||||||
|
: "maintenance.sync.error",
|
||||||
|
),
|
||||||
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
},
|
||||||
}
|
);
|
||||||
} catch {
|
|
||||||
toast.error(t("maintenance.sync.error"), {
|
|
||||||
position: "top-center",
|
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@@ -133,6 +133,9 @@ export default function TriggerView({
|
|||||||
|
|
||||||
// watch websocket for updates
|
// watch websocket for updates
|
||||||
const { payload: triggers_status_ws } = useTriggers();
|
const { payload: triggers_status_ws } = useTriggers();
|
||||||
|
const highlightTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!triggers_status_ws) return;
|
if (!triggers_status_ws) return;
|
||||||
@@ -148,13 +151,13 @@ export default function TriggerView({
|
|||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const name = triggers_status_ws.name;
|
||||||
|
clearTimeout(highlightTimers.current[name]);
|
||||||
|
highlightTimers.current[name] = setTimeout(() => {
|
||||||
setTriggeredTrigger((prev) =>
|
setTriggeredTrigger((prev) =>
|
||||||
(prev || []).filter((name) => name !== triggers_status_ws.name),
|
(prev || []).filter((triggered) => triggered !== name),
|
||||||
);
|
);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [triggers_status_ws, selectedCamera, mutate]);
|
}, [triggers_status_ws, selectedCamera, mutate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user