From 25209c81ae08146514cc6bac4bd60a7033655500 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 09:12:45 +0200 Subject: [PATCH 01/10] add camera reboot route and client method --- .../client/pyro_camera_api_client/client.py | 8 +++++ .../pyro_camera_api/api/routes_control.py | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/pyro_camera_api/client/pyro_camera_api_client/client.py b/pyro_camera_api/client/pyro_camera_api_client/client.py index 70adfae9..e590c62c 100644 --- a/pyro_camera_api/client/pyro_camera_api_client/client.py +++ b/pyro_camera_api/client/pyro_camera_api_client/client.py @@ -209,6 +209,14 @@ def zoom(self, camera_ip: str, level: int) -> Dict[str, Any]: resp = self._request("POST", f"/control/zoom/{camera_ip}/{level}") return resp.json() + def reboot_camera(self, camera_ip: str) -> Dict[str, Any]: + """ + Reboot a camera. Supported for Reolink and Linovision adapters. + Raises HTTP 501 for adapters that do not implement reboot. + """ + resp = self._request("POST", f"/control/reboot/{camera_ip}") + return resp.json() + # ------------------------------------------------------------------ # Focus # ------------------------------------------------------------------ diff --git a/pyro_camera_api/pyro_camera_api/api/routes_control.py b/pyro_camera_api/pyro_camera_api/api/routes_control.py index b5d0006b..3eee90c7 100644 --- a/pyro_camera_api/pyro_camera_api/api/routes_control.py +++ b/pyro_camera_api/pyro_camera_api/api/routes_control.py @@ -207,6 +207,35 @@ def set_preset(camera_ip: str, idx: Optional[int] = None): return {"status": "preset_set", "camera_ip": camera_ip, "id": idx} +@router.post("/reboot/{camera_ip}") +def reboot_camera(camera_ip: str): + """ + Reboot a camera. + + Supported for adapters that expose a reboot_camera method (Reolink, + Linovision). Used to recover cameras that occasionally get stuck + (e.g. PTZ stops responding during patrol). Returns 501 for adapters + that do not implement reboot. + """ + cam = CAMERA_REGISTRY.get(camera_ip) + if cam is None: + raise HTTPException(status_code=404, detail=f"Camera with IP '{camera_ip}' not found") + + if not hasattr(cam, "reboot_camera"): + raise HTTPException( + status_code=501, + detail="Reboot is not implemented for this camera adapter", + ) + + try: + logger.warning("[%s] Rebooting camera", camera_ip) + cam.reboot_camera() + return {"status": "rebooting", "camera_ip": camera_ip} + except Exception as exc: + logger.error("[%s] Failed to reboot camera: %s", camera_ip, exc) + raise HTTPException(status_code=500, detail=str(exc)) + + @router.post("/zoom/{camera_ip}/{level}") def zoom_camera(camera_ip: str, level: int): """ From 37104e49bd5293839409db245912a2eb253fd206 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 10:15:19 +0200 Subject: [PATCH 02/10] detect stuck PTZ via pairwise pHash and auto-reboot --- .../pyro_camera_api/camera/registry.py | 4 + .../pyro_camera_api/camera/stuck_detector.py | 138 ++++++++++++++++++ pyro_camera_api/pyro_camera_api/main.py | 23 ++- 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 pyro_camera_api/pyro_camera_api/camera/stuck_detector.py diff --git a/pyro_camera_api/pyro_camera_api/camera/registry.py b/pyro_camera_api/pyro_camera_api/camera/registry.py index f1269782..dc883445 100644 --- a/pyro_camera_api/pyro_camera_api/camera/registry.py +++ b/pyro_camera_api/pyro_camera_api/camera/registry.py @@ -28,6 +28,10 @@ PATROL_THREADS: Dict[str, threading.Thread] = {} PATROL_FLAGS: Dict[str, threading.Event] = {} +# Stuck-detector threading state, later managed in camera.stuck_detector +STUCK_CHECK_THREADS: Dict[str, threading.Thread] = {} +STUCK_CHECK_FLAGS: Dict[str, threading.Event] = {} + def build_camera_object(key: str, conf: dict) -> Optional[BaseCamera]: """ diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py new file mode 100644 index 00000000..2321b73d --- /dev/null +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -0,0 +1,138 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + + +""" +PTZ stuck-camera detector. + +Every CHECK_INTERVAL seconds, compute pairwise pHash distances across the +most recent per-pose images produced by the patrol loop. A turret that has +frozen returns near-identical frames for all poses, giving a very small +maximum pairwise distance. After CONSECUTIVE_HITS_BEFORE_REBOOT consecutive +low-distance checks, reboot the camera. + +Thresholds were calibrated on real sdis-77 captures: stuck-episode max +pairwise Hamming <= 6, working-patrol min pairwise Hamming >= 17. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List + +import cv2 +import numpy as np +from PIL import Image + +from pyro_camera_api.camera.registry import CAMERA_REGISTRY, PATROL_FLAGS, PATROL_THREADS + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL = 30 * 60.0 # seconds between checks +STUCK_MAX_HAMMING = 10 # max pairwise distance below which we suspect stuck +CONSECUTIVE_HITS_BEFORE_REBOOT = 2 +MIN_POSES_FOR_CHECK = 3 + +CONSECUTIVE_HITS: Dict[str, int] = {} + + +def _phash(img: Image.Image, hash_size: int = 8, highfreq_factor: int = 4) -> np.ndarray: + """Classic pHash: downscale to grayscale, DCT, threshold low-freq block on its median.""" + img_size = hash_size * highfreq_factor + gray = img.convert("L").resize((img_size, img_size), Image.LANCZOS) + arr = np.asarray(gray, dtype=np.float32) + dct = cv2.dct(arr) + low = dct[:hash_size, :hash_size] + med = np.median(low[1:, 1:]) + return (low > med).flatten() + + +def _hamming(a: np.ndarray, b: np.ndarray) -> int: + return int(np.count_nonzero(a != b)) + + +def _max_pairwise_hamming(images: List[Image.Image]) -> int: + hashes = [_phash(im) for im in images] + n = len(hashes) + return max(_hamming(hashes[i], hashes[j]) for i in range(n) for j in range(i + 1, n)) + + +def _patrol_is_running(camera_ip: str) -> bool: + thr = PATROL_THREADS.get(camera_ip) + flag = PATROL_FLAGS.get(camera_ip) + return bool(thr and thr.is_alive() and flag and not flag.is_set()) + + +def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: + cam = CAMERA_REGISTRY[camera_ip] + + if not hasattr(cam, "reboot_camera"): + logger.info("[%s] Stuck detector disabled: adapter does not support reboot", camera_ip) + return + + logger.info( + "[%s] Stuck detector started (interval=%ds, threshold=%d, consecutive=%d)", + camera_ip, + int(CHECK_INTERVAL), + STUCK_MAX_HAMMING, + CONSECUTIVE_HITS_BEFORE_REBOOT, + ) + + CONSECUTIVE_HITS[camera_ip] = 0 + + while not stop_flag.wait(CHECK_INTERVAL): + if not _patrol_is_running(camera_ip): + logger.debug("[%s] Stuck check skipped: patrol not running", camera_ip) + CONSECUTIVE_HITS[camera_ip] = 0 + continue + + images = [im for pose, im in cam.last_images.items() if pose != -1 and im is not None] + if len(images) < MIN_POSES_FOR_CHECK: + logger.debug( + "[%s] Stuck check skipped: only %d pose images available", + camera_ip, + len(images), + ) + continue + + try: + max_dist = _max_pairwise_hamming(images) + except Exception as exc: + logger.warning("[%s] Stuck check failed: %s", camera_ip, exc) + continue + + if max_dist < STUCK_MAX_HAMMING: + CONSECUTIVE_HITS[camera_ip] += 1 + logger.warning( + "[%s] Possible stuck PTZ: max pHash distance=%d across %d poses (hit %d/%d)", + camera_ip, + max_dist, + len(images), + CONSECUTIVE_HITS[camera_ip], + CONSECUTIVE_HITS_BEFORE_REBOOT, + ) + if CONSECUTIVE_HITS[camera_ip] >= CONSECUTIVE_HITS_BEFORE_REBOOT: + logger.error( + "[%s] Rebooting camera due to stuck PTZ detection (max distance=%d)", + camera_ip, + max_dist, + ) + try: + cam.reboot_camera() + cam.last_images.clear() + except Exception as exc: + logger.error("[%s] Reboot failed: %s", camera_ip, exc) + CONSECUTIVE_HITS[camera_ip] = 0 + else: + if CONSECUTIVE_HITS[camera_ip] > 0: + logger.info( + "[%s] Stuck detector cleared: max distance=%d", + camera_ip, + max_dist, + ) + CONSECUTIVE_HITS[camera_ip] = 0 + + logger.info("[%s] Stuck detector exited cleanly", camera_ip) diff --git a/pyro_camera_api/pyro_camera_api/main.py b/pyro_camera_api/pyro_camera_api/main.py index 2bc46d99..0627b830 100644 --- a/pyro_camera_api/pyro_camera_api/main.py +++ b/pyro_camera_api/pyro_camera_api/main.py @@ -20,7 +20,14 @@ from pyro_camera_api.api.routes_patrol import router as patrol_router from pyro_camera_api.api.routes_stream import router as stream_router from pyro_camera_api.camera.patrol import patrol_loop, static_loop -from pyro_camera_api.camera.registry import CAMERA_REGISTRY, PATROL_FLAGS, PATROL_THREADS +from pyro_camera_api.camera.registry import ( + CAMERA_REGISTRY, + PATROL_FLAGS, + PATROL_THREADS, + STUCK_CHECK_FLAGS, + STUCK_CHECK_THREADS, +) +from pyro_camera_api.camera.stuck_detector import stuck_check_loop from pyro_camera_api.core.logging import setup_logging from pyro_camera_api.services.anonymizer_rtsp import AnonymizerWorker, BoxStore, LastFrameStore from pyro_camera_api.services.stream import set_app_for_stream, stop_stream_if_idle @@ -66,6 +73,16 @@ async def lifespan(app: FastAPI): PATROL_FLAGS[cam_id] = stop_flag thread.start() + if getattr(cam, "cam_type", "static") == "ptz" and hasattr(cam, "reboot_camera"): + stuck_flag = threading.Event() + stuck_thread = threading.Thread( + target=stuck_check_loop, args=(cam_id, stuck_flag), daemon=True + ) + STUCK_CHECK_THREADS[cam_id] = stuck_thread + STUCK_CHECK_FLAGS[cam_id] = stuck_flag + stuck_thread.start() + logger.info("Starting stuck detector for PTZ camera %s", cam_id) + threading.Thread(target=stop_stream_if_idle, daemon=True).start() try: @@ -75,6 +92,10 @@ async def lifespan(app: FastAPI): logger.info("Stopping loop for camera %s", cam_id) flag.set() + for cam_id, flag in STUCK_CHECK_FLAGS.items(): + logger.info("Stopping stuck detector for camera %s", cam_id) + flag.set() + try: workers = getattr(app.state, "stream_workers", {}) for cam_id, p in list(workers.items()): From 111063c21ad516d876a207e361a639fba614da56 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 10:26:38 +0200 Subject: [PATCH 03/10] log every stuck check with measured pHash distance --- .../pyro_camera_api/camera/stuck_detector.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py index 2321b73d..7dc49d4c 100644 --- a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -85,13 +85,13 @@ def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: while not stop_flag.wait(CHECK_INTERVAL): if not _patrol_is_running(camera_ip): - logger.debug("[%s] Stuck check skipped: patrol not running", camera_ip) + logger.info("[%s] Stuck check skipped: patrol not running", camera_ip) CONSECUTIVE_HITS[camera_ip] = 0 continue images = [im for pose, im in cam.last_images.items() if pose != -1 and im is not None] if len(images) < MIN_POSES_FOR_CHECK: - logger.debug( + logger.info( "[%s] Stuck check skipped: only %d pose images available", camera_ip, len(images), @@ -104,6 +104,14 @@ def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: logger.warning("[%s] Stuck check failed: %s", camera_ip, exc) continue + logger.info( + "[%s] Stuck check: max pHash distance=%d across %d poses (threshold=%d)", + camera_ip, + max_dist, + len(images), + STUCK_MAX_HAMMING, + ) + if max_dist < STUCK_MAX_HAMMING: CONSECUTIVE_HITS[camera_ip] += 1 logger.warning( From 0d05961c685b4491b15d140a2a5e4fcda6dc7bb8 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 11:04:17 +0200 Subject: [PATCH 04/10] gate stuck detector behind ENABLE_STUCK_DETECTOR env var (default on) --- .env.example | 4 ++++ docker-compose.yml | 1 + pyro_camera_api/pyro_camera_api/main.py | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e04421de..245e9218 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ MEDIAMTX_SERVER_IP=1.2.3.4 # Docker image tag for both services (defaults to "latest" if unset) # Set to a specific version for pinned deployments, e.g. PYRO_ENGINE_VERSION=1.2.0 PYRO_ENGINE_VERSION=latest + +# Stuck-PTZ detector (auto-reboots a PTZ camera that stops rotating during patrol). +# Enabled by default. Set to "false" to disable. +ENABLE_STUCK_DETECTOR=true diff --git a/docker-compose.yml b/docker-compose.yml index 602672d3..41815e75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: CAM_USER: ${CAM_USER} CAM_PWD: ${CAM_PWD} MEDIAMTX_SERVER_IP: ${MEDIAMTX_SERVER_IP} + ENABLE_STUCK_DETECTOR: ${ENABLE_STUCK_DETECTOR:-true} volumes: - ./data:/usr/src/app/data restart: always diff --git a/pyro_camera_api/pyro_camera_api/main.py b/pyro_camera_api/pyro_camera_api/main.py index 0627b830..830dc4f6 100644 --- a/pyro_camera_api/pyro_camera_api/main.py +++ b/pyro_camera_api/pyro_camera_api/main.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import os import threading from contextlib import asynccontextmanager @@ -73,7 +74,17 @@ async def lifespan(app: FastAPI): PATROL_FLAGS[cam_id] = stop_flag thread.start() - if getattr(cam, "cam_type", "static") == "ptz" and hasattr(cam, "reboot_camera"): + stuck_detector_enabled = os.getenv("ENABLE_STUCK_DETECTOR", "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if ( + stuck_detector_enabled + and getattr(cam, "cam_type", "static") == "ptz" + and hasattr(cam, "reboot_camera") + ): stuck_flag = threading.Event() stuck_thread = threading.Thread( target=stuck_check_loop, args=(cam_id, stuck_flag), daemon=True @@ -82,6 +93,8 @@ async def lifespan(app: FastAPI): STUCK_CHECK_FLAGS[cam_id] = stuck_flag stuck_thread.start() logger.info("Starting stuck detector for PTZ camera %s", cam_id) + elif not stuck_detector_enabled and getattr(cam, "cam_type", "static") == "ptz": + logger.info("Stuck detector disabled by ENABLE_STUCK_DETECTOR for %s", cam_id) threading.Thread(target=stop_stream_if_idle, daemon=True).start() From baaeee4e97683c2e3665184e08b62e033bfdd7c7 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 11:05:58 +0200 Subject: [PATCH 05/10] run first stuck check 3mn after startup --- pyro_camera_api/pyro_camera_api/camera/stuck_detector.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py index 7dc49d4c..eedd4a03 100644 --- a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -32,6 +32,7 @@ logger = logging.getLogger(__name__) CHECK_INTERVAL = 30 * 60.0 # seconds between checks +INITIAL_DELAY = 3 * 60.0 # delay before the first check, lets patrol populate last_images STUCK_MAX_HAMMING = 10 # max pairwise distance below which we suspect stuck CONSECUTIVE_HITS_BEFORE_REBOOT = 2 MIN_POSES_FOR_CHECK = 3 @@ -74,16 +75,19 @@ def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: return logger.info( - "[%s] Stuck detector started (interval=%ds, threshold=%d, consecutive=%d)", + "[%s] Stuck detector started (initial=%ds, interval=%ds, threshold=%d, consecutive=%d)", camera_ip, + int(INITIAL_DELAY), int(CHECK_INTERVAL), STUCK_MAX_HAMMING, CONSECUTIVE_HITS_BEFORE_REBOOT, ) CONSECUTIVE_HITS[camera_ip] = 0 + next_delay = INITIAL_DELAY - while not stop_flag.wait(CHECK_INTERVAL): + while not stop_flag.wait(next_delay): + next_delay = CHECK_INTERVAL if not _patrol_is_running(camera_ip): logger.info("[%s] Stuck check skipped: patrol not running", camera_ip) CONSECUTIVE_HITS[camera_ip] = 0 From 4bbae5f77f300030ae0117fa283c84d38f3344e2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 11:19:30 +0200 Subject: [PATCH 06/10] confirm stuck hit after 3mn instead of 30mn --- pyro_camera_api/pyro_camera_api/camera/stuck_detector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py index eedd4a03..4858a68e 100644 --- a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -138,6 +138,9 @@ def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: except Exception as exc: logger.error("[%s] Reboot failed: %s", camera_ip, exc) CONSECUTIVE_HITS[camera_ip] = 0 + else: + # confirm the hit quickly rather than waiting a full interval + next_delay = INITIAL_DELAY else: if CONSECUTIVE_HITS[camera_ip] > 0: logger.info( From d9c258c919e8e269da0de16099d430924e0250c2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 11:40:36 +0200 Subject: [PATCH 07/10] skip stuck check on low-variance scenes to avoid fog false positives --- .../pyro_camera_api/camera/stuck_detector.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py index 4858a68e..ca11cce0 100644 --- a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -35,7 +35,11 @@ INITIAL_DELAY = 3 * 60.0 # delay before the first check, lets patrol populate last_images STUCK_MAX_HAMMING = 10 # max pairwise distance below which we suspect stuck CONSECUTIVE_HITS_BEFORE_REBOOT = 2 -MIN_POSES_FOR_CHECK = 3 +MIN_POSES_FOR_CHECK = 2 +# Fog / low-light scenes collapse to near-uniform gray and produce unstable pHashes. +# Skip the check when the mean per-image gray variance falls below this threshold. +# Calibrated on sun_test data: foggy rounds mean variance <= 332, stuck rounds mean >= 854. +MIN_MEAN_VARIANCE_FOR_CHECK = 500.0 CONSECUTIVE_HITS: Dict[str, int] = {} @@ -55,6 +59,10 @@ def _hamming(a: np.ndarray, b: np.ndarray) -> int: return int(np.count_nonzero(a != b)) +def _mean_gray_variance(images: List[Image.Image]) -> float: + return float(np.mean([np.asarray(im.convert("L"), dtype=np.float32).var() for im in images])) + + def _max_pairwise_hamming(images: List[Image.Image]) -> int: hashes = [_phash(im) for im in images] n = len(hashes) @@ -102,6 +110,22 @@ def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: ) continue + try: + mean_var = _mean_gray_variance(images) + except Exception as exc: + logger.warning("[%s] Stuck check failed computing variance: %s", camera_ip, exc) + continue + + if mean_var < MIN_MEAN_VARIANCE_FOR_CHECK: + logger.info( + "[%s] Stuck check skipped: low-variance scene (mean=%.0f < %d, likely fog/night)", + camera_ip, + mean_var, + int(MIN_MEAN_VARIANCE_FOR_CHECK), + ) + CONSECUTIVE_HITS[camera_ip] = 0 + continue + try: max_dist = _max_pairwise_hamming(images) except Exception as exc: From 7158dd2c69be6f3daf5992ec8c87e5565d32d1d4 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 24 Apr 2026 11:42:34 +0200 Subject: [PATCH 08/10] propagate reboot failures and fix latest_image race with last_images.clear --- .../pyro_camera_api/api/routes_cameras.py | 5 +++-- .../pyro_camera_api/api/routes_control.py | 9 +++++++-- .../pyro_camera_api/camera/adapters/reolink.py | 10 ++++++++-- .../pyro_camera_api/camera/stuck_detector.py | 18 ++++++++++++++---- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/pyro_camera_api/pyro_camera_api/api/routes_cameras.py b/pyro_camera_api/pyro_camera_api/api/routes_cameras.py index f7b961f6..744d4102 100644 --- a/pyro_camera_api/pyro_camera_api/api/routes_cameras.py +++ b/pyro_camera_api/pyro_camera_api/api/routes_cameras.py @@ -251,9 +251,10 @@ def get_latest_image( if cam is None: raise HTTPException(status_code=404, detail="Unknown camera") - if pose not in cam.last_images or cam.last_images[pose] is None: + image = cam.last_images.get(pose) + if image is None: return Response(status_code=status.HTTP_204_NO_CONTENT) buffer = BytesIO() - cam.last_images[pose].save(buffer, format="JPEG", quality=quality) + image.save(buffer, format="JPEG", quality=quality) return Response(buffer.getvalue(), media_type="image/jpeg") diff --git a/pyro_camera_api/pyro_camera_api/api/routes_control.py b/pyro_camera_api/pyro_camera_api/api/routes_control.py index 3eee90c7..bdc5621a 100644 --- a/pyro_camera_api/pyro_camera_api/api/routes_control.py +++ b/pyro_camera_api/pyro_camera_api/api/routes_control.py @@ -229,12 +229,17 @@ def reboot_camera(camera_ip: str): try: logger.warning("[%s] Rebooting camera", camera_ip) - cam.reboot_camera() - return {"status": "rebooting", "camera_ip": camera_ip} + ok = cam.reboot_camera() except Exception as exc: logger.error("[%s] Failed to reboot camera: %s", camera_ip, exc) raise HTTPException(status_code=500, detail=str(exc)) + if not ok: + logger.error("[%s] Camera rejected reboot command", camera_ip) + raise HTTPException(status_code=502, detail="Camera rejected reboot command") + + return {"status": "rebooting", "camera_ip": camera_ip} + @router.post("/zoom/{camera_ip}/{level}") def zoom_camera(camera_ip: str, level: int): diff --git a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py index 437f7d76..f49864a5 100644 --- a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py +++ b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py @@ -152,11 +152,17 @@ def set_ptz_preset(self, idx: Optional[int] = None): response = requests.post(url, json=data, verify=False) # nosec: B501 self._handle_response(response, f"Preset {name} set successfully.") - def reboot_camera(self): + def reboot_camera(self) -> bool: url = self._build_url("Reboot") data = [{"cmd": "Reboot"}] response = requests.post(url, json=data, verify=False) # nosec: B501 - return self._handle_response(response, "Camera reboot initiated successfully.") + response_data = self._handle_response(response, "Camera reboot initiated successfully.") + if not response_data: + return False + try: + return response_data[0]["code"] == 0 + except (KeyError, IndexError, TypeError): + return False def get_auto_focus(self): url = self._build_url("GetAutoFocus") diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py index ca11cce0..f353dc61 100644 --- a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -156,12 +156,22 @@ def stuck_check_loop(camera_ip: str, stop_flag: threading.Event) -> None: camera_ip, max_dist, ) + ok = False try: - cam.reboot_camera() - cam.last_images.clear() + ok = bool(cam.reboot_camera()) except Exception as exc: - logger.error("[%s] Reboot failed: %s", camera_ip, exc) - CONSECUTIVE_HITS[camera_ip] = 0 + logger.error("[%s] Reboot raised: %s", camera_ip, exc) + + if ok: + cam.last_images.clear() + CONSECUTIVE_HITS[camera_ip] = 0 + else: + logger.error( + "[%s] Reboot command rejected by camera, will retry on next check", + camera_ip, + ) + # keep the hit counter so we retry, use fast-confirm cadence + next_delay = INITIAL_DELAY else: # confirm the hit quickly rather than waiting a full interval next_delay = INITIAL_DELAY From 91a601eff4adbc6c6afa9e2ef72b24f93a22e9d2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Mon, 27 Apr 2026 08:47:32 +0200 Subject: [PATCH 09/10] style: ruff format --- pyro_camera_api/pyro_camera_api/main.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pyro_camera_api/pyro_camera_api/main.py b/pyro_camera_api/pyro_camera_api/main.py index 830dc4f6..d1e6f8a5 100644 --- a/pyro_camera_api/pyro_camera_api/main.py +++ b/pyro_camera_api/pyro_camera_api/main.py @@ -80,15 +80,9 @@ async def lifespan(app: FastAPI): "yes", "on", ) - if ( - stuck_detector_enabled - and getattr(cam, "cam_type", "static") == "ptz" - and hasattr(cam, "reboot_camera") - ): + if stuck_detector_enabled and getattr(cam, "cam_type", "static") == "ptz" and hasattr(cam, "reboot_camera"): stuck_flag = threading.Event() - stuck_thread = threading.Thread( - target=stuck_check_loop, args=(cam_id, stuck_flag), daemon=True - ) + stuck_thread = threading.Thread(target=stuck_check_loop, args=(cam_id, stuck_flag), daemon=True) STUCK_CHECK_THREADS[cam_id] = stuck_thread STUCK_CHECK_FLAGS[cam_id] = stuck_flag stuck_thread.start() From e4239272fd4f0f9600f69813324ed7a41454344c Mon Sep 17 00:00:00 2001 From: Mateo Date: Mon, 27 Apr 2026 08:50:48 +0200 Subject: [PATCH 10/10] fix: use Image.Resampling.LANCZOS for mypy --- pyro_camera_api/pyro_camera_api/camera/stuck_detector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py index f353dc61..aafeded5 100644 --- a/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py +++ b/pyro_camera_api/pyro_camera_api/camera/stuck_detector.py @@ -47,7 +47,7 @@ def _phash(img: Image.Image, hash_size: int = 8, highfreq_factor: int = 4) -> np.ndarray: """Classic pHash: downscale to grayscale, DCT, threshold low-freq block on its median.""" img_size = hash_size * highfreq_factor - gray = img.convert("L").resize((img_size, img_size), Image.LANCZOS) + gray = img.convert("L").resize((img_size, img_size), Image.Resampling.LANCZOS) arr = np.asarray(gray, dtype=np.float32) dct = cv2.dct(arr) low = dct[:hash_size, :hash_size]