Compare commits
7 Commits
8433951546
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 93eb4f4cac | |||
| 81b374aa92 | |||
| 3cb977c31c | |||
| 4575071124 | |||
| f1039db1ef | |||
| dda0daaee5 | |||
| 7a1e1f0be2 |
232
main.py
232
main.py
@@ -1,15 +1,21 @@
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
import json
|
||||
from os import getuid
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
import subprocess
|
||||
from time import sleep
|
||||
from time import monotonic, sleep
|
||||
from typing import Literal
|
||||
import pyudev
|
||||
from gpiozero import LED, Button
|
||||
from threading import Thread, Event
|
||||
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)
|
||||
yellow = LED(27)
|
||||
@@ -25,6 +31,7 @@ class DiskEvent:
|
||||
|
||||
|
||||
DiskState = Literal["disconnected", "pairing", "connecting", "connected"]
|
||||
CameraState = Literal["off", "armed", "recording"]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -35,10 +42,13 @@ class Config:
|
||||
@dataclass
|
||||
class AppState:
|
||||
stopEvent = Event()
|
||||
cameraStopComplete = Event()
|
||||
stopRecording = Event()
|
||||
diskEventQueue: Queue[DiskEvent] = Queue()
|
||||
diskState: DiskState = "disconnected"
|
||||
config = Config()
|
||||
failedUmount: bool = False
|
||||
cameraState = "off"
|
||||
|
||||
|
||||
def saveConfig(config: Config, path: Path) -> None:
|
||||
@@ -103,12 +113,14 @@ def diskPollWorker(state) -> None:
|
||||
|
||||
def ledWorker(state: AppState) -> None:
|
||||
yellowCtr = 0
|
||||
previousState: DiskState | None = None
|
||||
blueCtr = 0
|
||||
previousDiskState: DiskState | None = None
|
||||
previousCameraState: CameraState | None = None
|
||||
|
||||
while not state.stopEvent.is_set():
|
||||
if state.diskState != previousState:
|
||||
if state.diskState != previousDiskState:
|
||||
yellowCtr = 0
|
||||
previousState = state.diskState
|
||||
previousDiskState = state.diskState
|
||||
|
||||
if state.diskState == "disconnected":
|
||||
yellow.off()
|
||||
@@ -130,6 +142,22 @@ def ledWorker(state: AppState) -> None:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -152,10 +180,7 @@ def mountDisk(state: AppState) -> None:
|
||||
state.diskState = "connecting"
|
||||
node = findDiskNode(state.config.diskSerial)
|
||||
try:
|
||||
subprocess.run(
|
||||
["mkdir", "-p", f"/mnt/{state.config.diskSerial}"],
|
||||
check=True,
|
||||
)
|
||||
Path(f"/mnt/{state.config.diskSerial}").mkdir(parents=True, exist_ok=True)
|
||||
sleep(1)
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -169,12 +194,16 @@ def mountDisk(state: AppState) -> None:
|
||||
)
|
||||
sleep(1)
|
||||
except subprocess.CalledProcessError:
|
||||
state.diskState = "disconnected"
|
||||
pass
|
||||
else:
|
||||
state.diskState = "connected"
|
||||
|
||||
|
||||
def unmountDisk(state: AppState) -> None:
|
||||
state.cameraState = "armed"
|
||||
state.stopRecording.wait()
|
||||
state.stopRecording.clear()
|
||||
if not state.failedUmount:
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -190,6 +219,9 @@ def unmountDisk(state: AppState) -> None:
|
||||
else:
|
||||
state.diskState = "disconnected"
|
||||
else:
|
||||
print("force unmounting")
|
||||
subprocess.run(["fuser", "-km", f"/mnt/{state.config.diskSerial}"], check=False)
|
||||
subprocess.run(["sync"], check=False)
|
||||
subprocess.run(
|
||||
[
|
||||
"umount",
|
||||
@@ -202,6 +234,178 @@ def unmountDisk(state: AppState) -> None:
|
||||
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:
|
||||
try:
|
||||
state = AppState()
|
||||
@@ -226,6 +430,15 @@ def main() -> None:
|
||||
|
||||
ledThread.start()
|
||||
|
||||
cameraThread = Thread(
|
||||
target=cameraWorker,
|
||||
name="camera_thread",
|
||||
args=(state,),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
cameraThread.start()
|
||||
|
||||
if state.config.diskSerial != "":
|
||||
mountDisk(state)
|
||||
|
||||
@@ -254,8 +467,11 @@ def main() -> None:
|
||||
|
||||
finally:
|
||||
state.stopEvent.set()
|
||||
state.cameraStopComplete.wait()
|
||||
if state.diskState == "connected":
|
||||
unmountDisk(state)
|
||||
if state.failedUmount:
|
||||
unmountDisk(state)
|
||||
return
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user