Compare commits

...

7 Commits

232
main.py
View File

@@ -1,15 +1,21 @@
from datetime import datetime
from functools import partial from functools import partial
import json import json
from os import getuid from os import getuid
from pathlib import Path from pathlib import Path
from queue import Empty, Queue from queue import Empty, Queue
import subprocess import subprocess
from time import sleep from time import monotonic, sleep
from typing import Literal from typing import Literal
import pyudev import pyudev
from gpiozero import LED, Button from gpiozero import LED, Button
from threading import Thread, Event from threading import Thread, Event
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import numpy as np
from libcamera import controls
from picamera2 import Picamera2
from picamera2.encoders import H264Encoder
from picamera2.outputs import CircularOutput
red = LED(17) red = LED(17)
yellow = LED(27) yellow = LED(27)
@@ -25,6 +31,7 @@ class DiskEvent:
DiskState = Literal["disconnected", "pairing", "connecting", "connected"] DiskState = Literal["disconnected", "pairing", "connecting", "connected"]
CameraState = Literal["off", "armed", "recording"]
@dataclass @dataclass
@@ -35,10 +42,13 @@ class Config:
@dataclass @dataclass
class AppState: class AppState:
stopEvent = Event() stopEvent = Event()
cameraStopComplete = Event()
stopRecording = Event()
diskEventQueue: Queue[DiskEvent] = Queue() diskEventQueue: Queue[DiskEvent] = Queue()
diskState: DiskState = "disconnected" diskState: DiskState = "disconnected"
config = Config() config = Config()
failedUmount: bool = False failedUmount: bool = False
cameraState = "off"
def saveConfig(config: Config, path: Path) -> None: def saveConfig(config: Config, path: Path) -> None:
@@ -103,12 +113,14 @@ def diskPollWorker(state) -> None:
def ledWorker(state: AppState) -> None: def ledWorker(state: AppState) -> None:
yellowCtr = 0 yellowCtr = 0
previousState: DiskState | None = None blueCtr = 0
previousDiskState: DiskState | None = None
previousCameraState: CameraState | None = None
while not state.stopEvent.is_set(): while not state.stopEvent.is_set():
if state.diskState != previousState: if state.diskState != previousDiskState:
yellowCtr = 0 yellowCtr = 0
previousState = state.diskState previousDiskState = state.diskState
if state.diskState == "disconnected": if state.diskState == "disconnected":
yellow.off() yellow.off()
@@ -130,6 +142,22 @@ def ledWorker(state: AppState) -> None:
yellowCtr += 1 yellowCtr += 1
if state.cameraState != previousCameraState:
blueCtr = 0
previousCameraState = state.cameraState
if state.cameraState == "off":
blue.off()
elif state.cameraState == "armed":
blue.on()
elif state.cameraState == "recording":
if blueCtr % 4 < 2:
blue.on()
else:
blue.off()
blueCtr += 1
state.stopEvent.wait(0.25) state.stopEvent.wait(0.25)
@@ -152,10 +180,7 @@ def mountDisk(state: AppState) -> None:
state.diskState = "connecting" state.diskState = "connecting"
node = findDiskNode(state.config.diskSerial) node = findDiskNode(state.config.diskSerial)
try: try:
subprocess.run( Path(f"/mnt/{state.config.diskSerial}").mkdir(parents=True, exist_ok=True)
["mkdir", "-p", f"/mnt/{state.config.diskSerial}"],
check=True,
)
sleep(1) sleep(1)
subprocess.run( subprocess.run(
[ [
@@ -169,12 +194,16 @@ def mountDisk(state: AppState) -> None:
) )
sleep(1) sleep(1)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
state.diskState = "disconnected"
pass pass
else: else:
state.diskState = "connected" state.diskState = "connected"
def unmountDisk(state: AppState) -> None: def unmountDisk(state: AppState) -> None:
state.cameraState = "armed"
state.stopRecording.wait()
state.stopRecording.clear()
if not state.failedUmount: if not state.failedUmount:
try: try:
subprocess.run( subprocess.run(
@@ -190,6 +219,9 @@ def unmountDisk(state: AppState) -> None:
else: else:
state.diskState = "disconnected" state.diskState = "disconnected"
else: else:
print("force unmounting")
subprocess.run(["fuser", "-km", f"/mnt/{state.config.diskSerial}"], check=False)
subprocess.run(["sync"], check=False)
subprocess.run( subprocess.run(
[ [
"umount", "umount",
@@ -202,6 +234,178 @@ def unmountDisk(state: AppState) -> None:
state.failedUmount = False state.failedUmount = False
def cameraWorker(state: AppState) -> None:
VIDEO_SIZE = (1280, 720)
ANALYSIS_SIZE = (320, 240)
FRAME_RATE = 15
BITRATE = 2_000_000
QUIET_SECONDS = 10.0
PRE_RECORD_SECONDS = 2.0
WARMUP_SECONDS = 3.0
PIXEL_CHANGE_THRESHOLD = 25
MOTION_AREA_THRESHOLD = 0.015
ANALYSIS_SAMPLE_STEP = 2
OUTPUT_DIRECTORY = Path(f"/mnt/{state.config.diskSerial}/cctv")
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.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:
camera.start()
camera_started = True
camera.start_encoder(encoder)
encoder_started = True
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)
while not state.stopEvent.is_set():
if state.diskState != "connected":
state.cameraState = "off"
state.stopEvent.wait(1)
continue
if state.cameraState == "off":
state.cameraState = "armed"
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 = monotonic()
if motion_detected:
last_motion_time = now
if not recording:
if not OUTPUT_DIRECTORY.exists():
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
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
state.cameraState = "recording"
recording_trigger_time = now
elif (recording and now - last_motion_time >= QUIET_SECONDS) or (
recording and state.cameraState == "armed"
):
setGlobalFlag = False
if recording and state.cameraState == "armed":
setGlobalFlag = True
circular_output.stop()
state.cameraState = "armed"
recording = False
elapsed = now - recording_trigger_time
recording_path = None
if setGlobalFlag:
state.stopRecording.set()
previous_frame = current_frame
finally:
if recording:
circular_output.stop()
if encoder_started:
try:
camera.stop_encoder()
except Exception:
pass
if camera_started:
try:
camera.stop()
except Exception:
pass
camera.close()
state.cameraStopComplete.set()
def main() -> None: def main() -> None:
try: try:
state = AppState() state = AppState()
@@ -226,6 +430,15 @@ def main() -> None:
ledThread.start() ledThread.start()
cameraThread = Thread(
target=cameraWorker,
name="camera_thread",
args=(state,),
daemon=True,
)
cameraThread.start()
if state.config.diskSerial != "": if state.config.diskSerial != "":
mountDisk(state) mountDisk(state)
@@ -254,8 +467,11 @@ def main() -> None:
finally: finally:
state.stopEvent.set() state.stopEvent.set()
state.cameraStopComplete.wait()
if state.diskState == "connected": if state.diskState == "connected":
unmountDisk(state) unmountDisk(state)
if state.failedUmount:
unmountDisk(state)
return return