Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,6 @@ profile.svg

*.log
*.pt

# nightwatcher runtime
.nightwatcher.pid
109 changes: 109 additions & 0 deletions manage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# nightwatcher process manager

APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PID_FILE="$APP_DIR/.nightwatcher.pid"
LOG_FILE="$APP_DIR/nightwatcher.log"
CMD="uv run python -m nightwatcher.main"
PORT=12505

# ── color helpers ──────────────────────────────────────────────────────────────
green() { echo -e "\033[32m$*\033[0m"; }
yellow() { echo -e "\033[33m$*\033[0m"; }
red() { echo -e "\033[31m$*\033[0m"; }

# ── helpers ────────────────────────────────────────────────────────────────────
is_running() {
[ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
}

get_pid() {
cat "$PID_FILE" 2>/dev/null
}

# ── commands ───────────────────────────────────────────────────────────────────
do_start() {
if is_running; then
yellow "Already running (PID $(get_pid))"
return 1
fi
cd "$APP_DIR"
nohup $CMD >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 0.5
if is_running; then
green "Started (PID $(get_pid)) → log: $LOG_FILE"
else
red "Failed to start — check $LOG_FILE"
rm -f "$PID_FILE"
return 1
fi
}

do_stop() {
if ! is_running; then
yellow "Not running"
return 0
fi
local pid
pid=$(get_pid)
kill "$pid"
# wait up to 5 s for graceful exit
for i in $(seq 1 10); do
sleep 0.5
kill -0 "$pid" 2>/dev/null || break
done
if kill -0 "$pid" 2>/dev/null; then
yellow "Still alive after 5s — sending SIGKILL"
kill -9 "$pid"
fi
rm -f "$PID_FILE"
green "Stopped (PID $pid)"

# 等端口真正释放(最多 10 秒),顺手清掉残留占用者
for i in $(seq 1 20); do
local port_pids
port_pids=$(lsof -iTCP:$PORT -sTCP:LISTEN -t 2>/dev/null)
if [ -z "$port_pids" ]; then
break
fi
echo "$port_pids" | while read p; do
yellow "Port $PORT still held by PID $p — killing"
kill -9 "$p" 2>/dev/null
done
sleep 0.5
done
green "Port $PORT is free"
}

do_restart() {
yellow "Restarting…"
do_stop
sleep 0.5
do_start
}

do_status() {
if is_running; then
green "Running (PID $(get_pid))"
else
red "Not running"
fi
}

do_logs() {
tail -f "$LOG_FILE"
}

# ── dispatch ───────────────────────────────────────────────────────────────────
case "${1:-}" in
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
status) do_status ;;
logs) do_logs ;;
*)
echo "Usage: $0 {start|stop|restart|status|logs}"
exit 1
;;
esac
1 change: 1 addition & 0 deletions nightwatcher/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,5 @@ def handle_sigint(signum, frame) -> None:
favicon="🦇",
dark=None,
port=12505,
show=False,
)
42 changes: 34 additions & 8 deletions nightwatcher/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ class RTSPCameraStream:
- http://github.com/god233012yamil/How-to-Stream-a-Camera-Using-OpenCV-and-Threads
"""

# Number of consecutive read failures before triggering a reconnect.
MAX_READ_FAILURES = 5
# Short pause between retries (seconds) to avoid busy-looping.
RETRY_INTERVAL = 0.3

def __init__(self, url: str):
self.url = url
self.cap = None
self.is_running = False
self.thread = None
self.lock = threading.Lock()
self.frame: tuple[bool, cv2.typing.MatLike] | None = None
self._consecutive_failures = 0

self.logger = logging.getLogger(self.__class__.__name__)

Expand All @@ -31,7 +37,15 @@ def _connect(self) -> bool:
if self.cap is not None:
self.cap.release()

self.cap = cv2.VideoCapture(self.url)
self.cap = cv2.VideoCapture(
self.url,
cv2.CAP_FFMPEG,
[
cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5_000,
cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5_000,
],
)

if not self.cap.isOpened():
self.logger.error("Failed to open RTSP stream")
return False
Expand All @@ -50,9 +64,6 @@ def start(self) -> None:
self.logger.warning("Stream is already running")
return

if not self._connect():
return

self.is_running = True
self.thread = threading.Thread(target=self._update_frame, args=(), daemon=True)
self.thread.start()
Expand All @@ -77,18 +88,33 @@ def restart(self) -> None:
def _update_frame(self) -> None:
while self.is_running:
if self.cap is None or not self.cap.isOpened():
self.logger.info("Attemp to reconnect...")
self.logger.info("Attempt to reconnect...")
if not self._connect():
time.sleep(1)
continue
continue

ret, frame = self.cap.read()
if not ret:
# Retry interval and max times
self.logger.warning("Failed to read frame, reconnect...")
self._connect()
self._consecutive_failures += 1
self.logger.warning(
"Failed to read frame (%d/%d)",
self._consecutive_failures,
self.MAX_READ_FAILURES,
)
if self._consecutive_failures < self.MAX_READ_FAILURES:
time.sleep(self.RETRY_INTERVAL)
continue
# Exceeded threshold — reconnect
self.logger.warning("Too many consecutive failures, reconnecting...")
self._consecutive_failures = 0
if not self._connect():
time.sleep(2)
else:
time.sleep(0.5)
continue

self._consecutive_failures = 0
with self.lock:
self.frame = (ret, frame)
self.logger.debug(f"Update frame: {ret}")
Expand Down