Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3.0.0b11 bugfixes #840

Merged
merged 27 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
785cd8a
add SnapshotDomain type to get_snapshot_path
roflcoopter Nov 18, 2024
02f2fe2
allow empty password in camera config
roflcoopter Nov 18, 2024
d2644e9
remove volumes from Dockerfile
roflcoopter Nov 18, 2024
104b19e
run watchdogs in the global background scheduler
roflcoopter Nov 18, 2024
fb5e670
add a timeout when waiting for detection result for darknet
roflcoopter Nov 18, 2024
93482e6
implement RestartableProcess for child process management
roflcoopter Nov 19, 2024
7141a35
add debug logging to cpai detect function
roflcoopter Nov 20, 2024
802b074
add logging for shutdown process and improve shutdown handling
roflcoopter Nov 20, 2024
c33e8af
allow return_objects to return None for darknet
roflcoopter Nov 22, 2024
479fadb
replace threading.Thread with RestartableThread for MQTT callback exe…
roflcoopter Nov 22, 2024
e754788
close queues when restarting process in ChildProcessWorker
roflcoopter Nov 22, 2024
1b23e65
only wait for camera to stop if it is started in Fragmenter
roflcoopter Nov 22, 2024
91e6e46
create event clip dir when concatenation is finished
roflcoopter Nov 22, 2024
37168ca
replace threading.Thread with RestartableThread for fragment concaten…
roflcoopter Nov 22, 2024
0e4a7c0
add SCANNER_RESULT_RETRIES constant and implement retry logic for fra…
roflcoopter Nov 22, 2024
92c3b86
set frame removal timers as daemons in NVR
roflcoopter Nov 22, 2024
d7faa66
refactor NVR recorder logic to use datetime for stopping conditions a…
roflcoopter Nov 22, 2024
1780c8e
ignore changes to latest_thumbnail.jpg in ThumbnailTierHandler
roflcoopter Nov 22, 2024
f1977f8
fix move_on_shutdown
roflcoopter Nov 24, 2024
751e3b7
enhance file deletion logic for FilesMeta, and improve error handling…
roflcoopter Nov 24, 2024
4f85bd7
correctly set name for RestartableProcess
roflcoopter Nov 26, 2024
72eeef5
add missing subcategory to force_move_files
roflcoopter Nov 26, 2024
e7dc02c
fix check_tier throttle
roflcoopter Nov 26, 2024
1c31be7
generate event clip name in servers timezone
roflcoopter Nov 26, 2024
fba119e
add multiple cleanup jobs to make sure the db and filesystem is in sync
roflcoopter Nov 26, 2024
63c09fe
add indexes for cleanup jobs to improve database query performance
roflcoopter Nov 26, 2024
a971541
update mocks in cpai tests
roflcoopter Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,6 @@ RUN \
&& useradd --uid 911 --user-group --create-home abc \
&& mkdir -p /home/abc/bin /segments

VOLUME /config
VOLUME /recordings
VOLUME /segments
VOLUME /snapshots
VOLUME /thumbnails

ENTRYPOINT ["/init"]

COPY docker/ffprobe_wrapper /home/abc/bin/ffprobe
Expand Down
4 changes: 4 additions & 0 deletions rootfs/etc/services.d/viseron/finish
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ define VISERON_RESTART_EXIT_CODE 100
define SIGNAL_EXIT_CODE 256
define SIGTERM 15

# Log the exit code and time
foreground { s6-echo "[viseron-finish] Viseron exit code ${1}" }
backtick -D "unknown time" date { /bin/date }
importas -i date date
foreground { s6-echo "[viseron-finish] Shutdown completed at ${date}" }

# Exit without stopping the supervisor so the Viseron service restarts on its own
if { s6-test ${1} -ne ${VISERON_RESTART_EXIT_CODE} }
Expand Down
12 changes: 6 additions & 6 deletions tests/components/codeprojectai/test_object_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def test_object_detector_init(vis: MockViseron, config):
"""
_ = MockComponent(COMPONENT, vis)
_ = MockCamera(vis, identifier=CAMERA_IDENTIFIER)
with patch("codeprojectai.core.CodeProjectAIObject"):
with patch("viseron.components.codeprojectai.object_detector.CodeProjectAIObject"):
detector = ObjectDetector(vis, config["codeprojectai"], CAMERA_IDENTIFIER)
assert detector._image_resolution == ( # pylint: disable=protected-access
640,
Expand All @@ -108,7 +108,7 @@ def test_preprocess(vis: Viseron, config):
"""
_ = MockComponent(COMPONENT, vis)
_ = MockCamera(vis, identifier=CAMERA_IDENTIFIER)
with patch("codeprojectai.core.CodeProjectAIObject"):
with patch("viseron.components.codeprojectai.object_detector.CodeProjectAIObject"):
detector = ObjectDetector(vis, config["codeprojectai"], CAMERA_IDENTIFIER)
frame = np.zeros((480, 640, 3), dtype=np.uint8)
processed = detector.preprocess(frame)
Expand All @@ -125,7 +125,7 @@ def test_postprocess(vis: Viseron, config):
"""
_ = MockComponent(COMPONENT, vis)
_ = MockCamera(vis, identifier=CAMERA_IDENTIFIER)
with patch("codeprojectai.core.CodeProjectAIObject"):
with patch("viseron.components.codeprojectai.object_detector.CodeProjectAIObject"):
detector = ObjectDetector(vis, config["codeprojectai"], CAMERA_IDENTIFIER)
detections = [
{
Expand All @@ -142,7 +142,7 @@ def test_postprocess(vis: Viseron, config):
assert isinstance(objects[0], DetectedObject)


@patch("codeprojectai.core.CodeProjectAIObject.detect")
@patch("viseron.components.codeprojectai.object_detector.CodeProjectAIObject.detect")
def test_return_objects_success(mock_detect, vis: Viseron, config):
"""
Test the return_objects method of the ObjectDetector class for successful detection.
Expand Down Expand Up @@ -203,7 +203,7 @@ def test_object_detector_init_no_image_size(vis: Viseron, config, mock_detected_
config (dict): The configuration dictionary.
mock_detected_object (MagicMock): Mocked DetectedObject class.
"""
with patch("codeprojectai.core.CodeProjectAIObject"):
with patch("viseron.components.codeprojectai.object_detector.CodeProjectAIObject"):
# Set non-square image resolution
config["codeprojectai"]["object_detector"]["image_size"] = None

Expand Down Expand Up @@ -255,7 +255,7 @@ def test_postprocess_square_resolution(vis: Viseron, config, mock_detected_objec
config (dict): The configuration dictionary.
mock_detected_object (MagicMock): Mocked DetectedObject class.
"""
with patch("codeprojectai.core.CodeProjectAIObject"):
with patch("viseron.components.codeprojectai.object_detector.CodeProjectAIObject"):
# Set square image resolution
config["codeprojectai"]["object_detector"]["image_size"] = 640

Expand Down
32 changes: 21 additions & 11 deletions tests/components/ffmpeg/test_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
DEFAULT_CODEC,
DEFAULT_FPS,
DEFAULT_HEIGHT,
DEFAULT_PASSWORD,
DEFAULT_PROTOCOL,
DEFAULT_RECORDER_AUDIO_CODEC,
DEFAULT_STREAM_FORMAT,
Expand All @@ -48,7 +49,7 @@

from tests.common import MockCamera, return_any

CONFIG = {
CONFIG: dict[str, Any] = {
CONFIG_HOST: "test_host",
CONFIG_PORT: 1234,
CONFIG_PATH: "/",
Expand Down Expand Up @@ -211,22 +212,31 @@ def test_get_encoder_audio_codec(
stream.get_encoder_audio_codec(stream_audio_codec) == expected_audio_cmd
)

def test_get_stream_url(self) -> None:
@pytest.mark.parametrize(
"username, password, expected_url",
[
(
"test_username",
"test_password",
"rtsp://test_username:test_password@test_host:1234/",
),
("admin", "", "rtsp://admin:@test_host:1234/"),
(DEFAULT_USERNAME, DEFAULT_PASSWORD, "rtsp://test_host:1234/"),
],
)
def test_get_stream_url(self, username, password, expected_url) -> None:
"""Test that the correct stream url is returned."""
mocked_camera = MockCamera(identifier="test_camera_identifier")
config = dict(CONFIG)
config[CONFIG_USERNAME] = username
config[CONFIG_PASSWORD] = password

with patch.object(
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
):
stream = Stream(CONFIG, mocked_camera, "test_camera_identifier")
stream._config = CONFIG # pylint: disable=protected-access
assert (
stream.get_stream_url(CONFIG)
== "rtsp://test_username:test_password@test_host:1234/"
)
stream._config[ # pylint: disable=protected-access
CONFIG_USERNAME
] = DEFAULT_USERNAME
assert stream.get_stream_url(CONFIG) == "rtsp://test_host:1234/"
stream._config = config # pylint: disable=protected-access
assert stream.get_stream_url(config) == expected_url

def test_get_stream_information(self):
"""Test that the correct stream information is returned."""
Expand Down
5 changes: 3 additions & 2 deletions tests/components/storage/test__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock, Mock, patch

import pytest

Expand Down Expand Up @@ -133,7 +133,8 @@ class TestStorage:

def setup_method(self, vis: Viseron) -> None:
"""Set up the test."""
self._storage = Storage(vis, MagicMock())
with patch("viseron.components.storage.CleanupManager"):
self._storage = Storage(vis, MagicMock())

def test_search_file(self) -> None:
"""Test the search_file method."""
Expand Down
6 changes: 4 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ class MockViseron(Viseron):

def __init__(self) -> None:
super().__init__()
self.register_domain = Mock(side_effect=self.register_domain) # type: ignore
self.mocked_register_domain = self.register_domain # type: ignore
self.register_domain = Mock( # type: ignore[method-assign]
side_effect=self.register_domain,
)
self.mocked_register_domain = self.register_domain


@pytest.fixture
Expand Down
37 changes: 25 additions & 12 deletions viseron/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
)
from viseron.states import States
from viseron.types import SupportedDomains
from viseron.watchdog.process_watchdog import ProcessWatchDog
from viseron.watchdog.subprocess_watchdog import SubprocessWatchDog
from viseron.watchdog.thread_watchdog import ThreadWatchDog

Expand Down Expand Up @@ -190,8 +191,7 @@ def setup_viseron() -> Viseron:
else:
vis.critical_components_config_store.save(config)

end = timer()
LOGGER.info("Viseron initialized in %.1f seconds", end - start)
LOGGER.info("Viseron initialized in %.1f seconds", timer() - start)
return vis


Expand All @@ -218,10 +218,11 @@ def __init__(self) -> None:
self._domain_register_lock = threading.Lock()
self.data[REGISTERED_DOMAINS] = {}

self._thread_watchdog = ThreadWatchDog()
self._subprocess_watchdog = SubprocessWatchDog()
self.background_scheduler = BackgroundScheduler(timezone="UTC", daemon=True)
self.background_scheduler.start()
self._thread_watchdog = ThreadWatchDog(self)
self._subprocess_watchdog = SubprocessWatchDog(self)
self._process_watchdog = ProcessWatchDog(self)

self.storage: Storage | None = None

Expand Down Expand Up @@ -265,7 +266,7 @@ def register_signal_handler(self, viseron_signal, callback):
return False

return self.data[DATA_STREAM_COMPONENT].subscribe_data(
f"viseron/signal/{viseron_signal}", callback
VISERON_SIGNALS[viseron_signal], callback, stage=viseron_signal
)

def listen_event(self, event: str, callback, ioloop=None) -> Callable[[], None]:
Expand Down Expand Up @@ -482,15 +483,14 @@ def get_registered_identifiers(self, domain: SupportedDomains):

def shutdown(self) -> None:
"""Shut down Viseron."""
start = timer()
LOGGER.info("Initiating shutdown")

if self.data.get(DATA_STREAM_COMPONENT, None):
data_stream: DataStream = self.data[DATA_STREAM_COMPONENT]

self._thread_watchdog.stop()
self._subprocess_watchdog.stop()
try:
self.background_scheduler.shutdown()
self.background_scheduler.shutdown(wait=False)
except SchedulerNotRunningError as err:
LOGGER.warning(f"Failed to shutdown scheduler: {err}")

Expand All @@ -504,7 +504,7 @@ def shutdown(self) -> None:
self, data_stream, VISERON_SIGNAL_STOPPING
)

LOGGER.info("Shutdown complete")
LOGGER.info("Shutdown complete in %.1f seconds", timer() - start)

def add_entity(self, component: str, entity: Entity):
"""Add entity to states registry."""
Expand Down Expand Up @@ -543,18 +543,31 @@ def wait_for_threads_and_processes_to_exit(
stage: Literal["shutdown", "last_write", "stopping"],
) -> None:
"""Wait for all threads and processes to exit."""
LOGGER.debug(f"Sending signal for stage {stage}")
vis.shutdown_stage = stage
data_stream.publish_data(VISERON_SIGNALS[stage])

time.sleep(0.1) # Wait for signal to be processed
LOGGER.debug(f"Waiting for threads and processes to exit in stage {stage}")

def join(
thread_or_process: threading.Thread
| multiprocessing.Process
| multiprocessing.process.BaseProcess,
) -> None:
thread_or_process.join(timeout=10)
time.sleep(0.5) # Wait for process to exit properly
start_time = time.time()
LOGGER.debug(f"Waiting for {thread_or_process.name} to exit")
try:
thread_or_process.join(timeout=5)
except RuntimeError:
LOGGER.debug(f"Failed to join {thread_or_process.name}")
time.sleep(0.1)
thread_or_process.join(timeout=5)
LOGGER.debug(
f"Finished waiting for {thread_or_process.name} "
f"after {time.time() - start_time:.2f}s"
)

time.sleep(0.1) # Wait for process to exit properly
if thread_or_process.is_alive():
LOGGER.error(f"{thread_or_process.name} did not exit in time")
if isinstance(thread_or_process, multiprocessing.Process):
Expand Down
14 changes: 14 additions & 0 deletions viseron/__main__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Start Viseron."""
from __future__ import annotations

import multiprocessing as mp
import os
import signal
import sys
import threading
from threading import Timer

from viseron import Viseron, setup_viseron

Expand All @@ -15,6 +19,16 @@ def signal_term(*_) -> None:
if viseron:
viseron.shutdown()

def shutdown_failed():
print("Shutdown failed. Exiting forcefully.")
print(f"Active threads: {threading.enumerate()}")
print(f"Active processes: {mp.active_children()}")
os.kill(os.getpid(), signal.SIGKILL)

shutdown_timer = Timer(2, shutdown_failed, args=())
shutdown_timer.daemon = True
shutdown_timer.start()

# Listen to signals
signal.signal(signal.SIGTERM, signal_term)
signal.signal(signal.SIGINT, signal_term)
Expand Down
20 changes: 19 additions & 1 deletion viseron/components/codeprojectai/object_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self, vis: Viseron, config, camera_identifier) -> None:
)

self._ds_config = config
self._detector = cpai.CodeProjectAIObject(
self._detector = CodeProjectAIObject(
ip=config[CONFIG_HOST],
port=config[CONFIG_PORT],
timeout=config[CONFIG_TIMEOUT],
Expand Down Expand Up @@ -109,3 +109,21 @@ def return_objects(self, frame):
return []

return self.postprocess(detections)


class CodeProjectAIObject(cpai.CodeProjectAIObject):
"""CodeProject.AI object detection."""

def __init__(self, ip, port, timeout, min_confidence, custom_model):
super().__init__(ip, port, timeout, min_confidence, custom_model)

def detect(self, image_bytes: bytes):
"""Process image_bytes and detect."""
response = cpai.process_image(
url=self._url_detect,
image_bytes=image_bytes,
min_confidence=self.min_confidence,
timeout=self.timeout,
)
LOGGER.debug("CodeProject.AI response: %s", response)
return response["predictions"]
20 changes: 15 additions & 5 deletions viseron/components/darknet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import os
import pwd
from abc import ABC, abstractmethod
from queue import Queue
from queue import Empty, Queue
from typing import Any

import cv2
import numpy as np
import voluptuous as vol

from viseron import Viseron
Expand Down Expand Up @@ -272,7 +273,7 @@ def spawn_subprocess(self) -> RestartablePopen:
stderr=self._log_pipe,
)

def preprocess(self, frame):
def preprocess(self, frame) -> np.ndarray:
"""Pre process frame before detection."""
return cv2.resize(
frame,
Expand Down Expand Up @@ -421,11 +422,17 @@ def work_output(self, item) -> None:
"""Put result into queue."""
pop_if_full(self._result_queues[item["camera_identifier"]], item)

def preprocess(self, frame):
def preprocess(self, frame) -> bytes:
"""Pre process frame before detection."""
return letterbox_resize(frame, self.model_width, self.model_height).tobytes()

def detect(self, frame, camera_identifier, result_queue, min_confidence):
def detect(
self,
frame: np.ndarray,
camera_identifier: str,
result_queue,
min_confidence: float,
):
"""Perform detection."""
self._result_queues[camera_identifier] = result_queue
pop_if_full(
Expand All @@ -436,7 +443,10 @@ def detect(self, frame, camera_identifier, result_queue, min_confidence):
"min_confidence": min_confidence,
},
)
item = result_queue.get()
try:
item = result_queue.get(timeout=3)
except Empty:
return None
return item["result"]

def post_process(self, detections, camera_resolution):
Expand Down
Loading