commit dcc44b05bd0244b40ab4f03789a6ebcb1299ccc2 Author: Hector van der Aa Date: Wed Jul 29 13:29:46 2026 +0100 Initial commit diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/disk_connected_test.py b/disk_connected_test.py new file mode 100644 index 0000000..6bf2b8e --- /dev/null +++ b/disk_connected_test.py @@ -0,0 +1,27 @@ +import pyudev + + +def main() -> None: + context = pyudev.Context() + + monitor = pyudev.Monitor.from_netlink(context) + monitor.filter_by(subsystem="block") + + print("Waiting for disks...") + + for device in iter(monitor.poll, None): + if device.action == "add" and device.device_type == "disk": + print("\nDisk connected") + print(f"Device: {device.device_node}") + print(f"Name: {device.sys_name}") + print(f"Model: {device.get('ID_MODEL', 'Unknown')}") + print(f"Serial: {device.get('ID_SERIAL_SHORT', 'Unknown')}") + print(f"Bus: {device.get('ID_BUS', 'Unknown')}") + + elif device.action == "remove" and device.device_type == "disk": + print("\nDisk disconnected") + print(f"Name: {device.sys_name}") + + +if __name__ == "__main__": + main() diff --git a/led_test.py b/led_test.py new file mode 100644 index 0000000..3b23fbe --- /dev/null +++ b/led_test.py @@ -0,0 +1,11 @@ +from gpiozero import LED +from time import sleep + +red = LED(17) +yellow = LED(27) +blue = LED(22) + +for led in (red, yellow, blue): + led.on() + sleep(1) + led.off() diff --git a/main.py b/main.py new file mode 100644 index 0000000..6bf2b8e --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +import pyudev + + +def main() -> None: + context = pyudev.Context() + + monitor = pyudev.Monitor.from_netlink(context) + monitor.filter_by(subsystem="block") + + print("Waiting for disks...") + + for device in iter(monitor.poll, None): + if device.action == "add" and device.device_type == "disk": + print("\nDisk connected") + print(f"Device: {device.device_node}") + print(f"Name: {device.sys_name}") + print(f"Model: {device.get('ID_MODEL', 'Unknown')}") + print(f"Serial: {device.get('ID_SERIAL_SHORT', 'Unknown')}") + print(f"Bus: {device.get('ID_BUS', 'Unknown')}") + + elif device.action == "remove" and device.device_type == "disk": + print("\nDisk disconnected") + print(f"Name: {device.sys_name}") + + +if __name__ == "__main__": + main() diff --git a/motion_detect.py b/motion_detect.py new file mode 100755 index 0000000..e363d9e --- /dev/null +++ b/motion_detect.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 + +from datetime import datetime +from pathlib import Path +import os +import signal +import sys +import time + +import numpy as np +from libcamera import controls +from picamera2 import Picamera2 +from picamera2.encoders import H264Encoder +from picamera2.outputs import CircularOutput + + +# --------------------------------------------------------------------------- +# Recording configuration +# --------------------------------------------------------------------------- + +OUTPUT_DIRECTORY = Path("/mnt/output/camera") + +VIDEO_SIZE = (1280, 720) +ANALYSIS_SIZE = (320, 240) + +FRAME_RATE = 15 +BITRATE = 2_000_000 + +# Continue recording for this long after the last detected movement. +QUIET_SECONDS = 10.0 + +# Include this much video from immediately before movement was detected. +PRE_RECORD_SECONDS = 2.0 + +# Allow auto-exposure and white balance to settle before arming detection. +WARMUP_SECONDS = 3.0 + + +# --------------------------------------------------------------------------- +# Motion-detection configuration +# --------------------------------------------------------------------------- + +# A pixel must change by at least this much, from 0 to 255, to count as changed. +PIXEL_CHANGE_THRESHOLD = 25 + +# Motion is detected when this fraction of analysed pixels has changed. +# 0.015 means 1.5 percent of the image. +MOTION_AREA_THRESHOLD = 0.015 + +# Analyse every second pixel horizontally and vertically to reduce CPU use. +ANALYSIS_SAMPLE_STEP = 2 + + +shutdown_requested = False + + +def log(message: str) -> None: + """Print a timestamped console message immediately.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] {message}", flush=True) + + +def request_shutdown(_signum: int, _frame: object) -> None: + global shutdown_requested + shutdown_requested = True + + +def main() -> int: + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + + if not os.access(OUTPUT_DIRECTORY, os.W_OK): + log(f"ERROR: Output directory is not writable: {OUTPUT_DIRECTORY}") + return 1 + + signal.signal(signal.SIGINT, request_shutdown) + signal.signal(signal.SIGTERM, request_shutdown) + + camera = Picamera2() + + configuration = camera.create_video_configuration( + main={ + "size": VIDEO_SIZE, + "format": "YUV420", + }, + lores={ + "size": ANALYSIS_SIZE, + "format": "YUV420", + }, + controls={ + "FrameRate": FRAME_RATE, + }, + buffer_count=6, + ) + + camera.configure(configuration) + + # Camera Module 3 continuously adjusts focus when necessary. + camera.set_controls({ + "AfMode": controls.AfModeEnum.Continuous, + }) + + encoder = H264Encoder( + bitrate=BITRATE, + repeat=True, + iperiod=FRAME_RATE, + ) + + prebuffer_frames = max( + 1, + round(FRAME_RATE * PRE_RECORD_SECONDS), + ) + + circular_output = CircularOutput( + buffersize=prebuffer_frames, + ) + + encoder.output = circular_output + + recording = False + recording_path: Path | None = None + recording_trigger_time = 0.0 + last_motion_time = 0.0 + + camera_started = False + encoder_started = False + + try: + log("Starting camera") + + camera.start() + camera_started = True + + # The encoder runs continuously. While idle, CircularOutput keeps only + # the configured short pre-trigger buffer in RAM. + camera.start_encoder(encoder) + encoder_started = True + + log( + f"Camera running at {VIDEO_SIZE[0]}x{VIDEO_SIZE[1]}, " + f"{FRAME_RATE} FPS" + ) + log( + f"Warming up for {WARMUP_SECONDS:.1f} seconds before " + "arming motion detection" + ) + + time.sleep(WARMUP_SECONDS) + + analysis_width, analysis_height = ANALYSIS_SIZE + + first_frame = camera.capture_array("lores") + previous_frame = first_frame[:analysis_height, :analysis_width] + previous_frame = previous_frame[ + ::ANALYSIS_SAMPLE_STEP, + ::ANALYSIS_SAMPLE_STEP, + ].astype(np.int16) + + log("Motion detection armed") + log( + f"Recordings will be saved in {OUTPUT_DIRECTORY}; " + f"recording stops after {QUIET_SECONDS:.0f} seconds " + "without motion" + ) + + while not shutdown_requested: + frame = camera.capture_array("lores") + + # YUV420 starts with the full-resolution greyscale Y plane. + current_frame = frame[:analysis_height, :analysis_width] + current_frame = current_frame[ + ::ANALYSIS_SAMPLE_STEP, + ::ANALYSIS_SAMPLE_STEP, + ].astype(np.int16) + + difference = np.abs(current_frame - previous_frame) + + changed_pixels = np.count_nonzero( + difference >= PIXEL_CHANGE_THRESHOLD + ) + + changed_fraction = changed_pixels / difference.size + motion_detected = ( + changed_fraction >= MOTION_AREA_THRESHOLD + ) + + now = time.monotonic() + + if motion_detected: + last_motion_time = now + + if not recording: + filename_timestamp = datetime.now().strftime( + "%Y-%m-%d_%H-%M-%S" + ) + + recording_path = ( + OUTPUT_DIRECTORY + / f"{filename_timestamp}.h264" + ) + + # Start writing the circular pre-buffer and all subsequent + # encoded H.264 packets directly to the file. + circular_output.fileoutput = str(recording_path) + circular_output.start() + + recording = True + recording_trigger_time = now + + log( + "RECORDING STARTED | " + f"motion area={changed_fraction * 100:.2f}%" + ) + log(f"Output file: {recording_path}") + + elif ( + recording + and now - last_motion_time >= QUIET_SECONDS + ): + circular_output.stop() + recording = False + + elapsed = now - recording_trigger_time + + if ( + recording_path is not None + and recording_path.exists() + ): + size_mib = ( + recording_path.stat().st_size + / (1024 * 1024) + ) + + log( + "RECORDING STOPPED | " + f"no motion for {QUIET_SECONDS:.0f} seconds | " + f"trigger-to-stop duration={elapsed:.1f} seconds | " + f"size={size_mib:.2f} MiB" + ) + log(f"Saved: {recording_path}") + else: + log( + "RECORDING STOPPED | " + f"no motion for {QUIET_SECONDS:.0f} seconds" + ) + + recording_path = None + + previous_frame = current_frame + + except KeyboardInterrupt: + log("Shutdown requested") + + except Exception as error: + log(f"ERROR: {type(error).__name__}: {error}") + return 1 + + finally: + if recording: + try: + circular_output.stop() + + if ( + recording_path is not None + and recording_path.exists() + ): + size_mib = ( + recording_path.stat().st_size + / (1024 * 1024) + ) + log( + "RECORDING STOPPED | " + f"program shutting down | " + f"size={size_mib:.2f} MiB" + ) + log(f"Saved: {recording_path}") + except Exception as error: + log(f"Error closing recording: {error}") + + if encoder_started: + try: + camera.stop_encoder() + except Exception: + pass + + if camera_started: + try: + camera.stop() + except Exception: + pass + + camera.close() + log("Camera stopped") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8a4d28a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "plotcctv" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pyudev>=0.24.4", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..622765f --- /dev/null +++ b/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "plotcctv" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyudev" }, +] + +[package.metadata] +requires-dist = [{ name = "pyudev", specifier = ">=0.24.4" }] + +[[package]] +name = "pyudev" +version = "0.24.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/8bdbf651de1002e8b58fbe817bee22b1e8bfcdd24341d42c3238ce9a75f4/pyudev-0.24.4.tar.gz", hash = "sha256:e788bb983700b1a84efc2e88862b0a51af2a995d5b86bc9997546505cf7b36bc", size = 56135, upload-time = "2025-10-08T17:26:58.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/51/3dc0cd6498b24dea3cdeaed648568e3ca7454d41334d840b114156d7479f/pyudev-0.24.4-py3-none-any.whl", hash = "sha256:b3b6b01c68e6fc628428cc45ff3fe6c277afbb5d96507f14473ddb4a6b959e00", size = 62784, upload-time = "2025-10-08T17:26:57.664Z" }, +]