Last active
February 10, 2022 00:57
-
-
Save mwvent/53775ab7af90edfbc71d54c2b2c1d1cb to your computer and use it in GitHub Desktop.
PI Zero Bicycle Dashcam Python Service
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/python3 | |
# Python script for using my Pi Zero as a Bicycle Dashcam which also live streams | |
# to an rtsp-simple-server running at home | |
# Recommended to start as a user systemd service | |
# This script requires user access to /sys/devices/platform/leds/leds /sys/class/leds | |
# Also recordings folder tends to corrupt very easily and should be its own filesystem | |
# I use NTFS to allow police/other windows 10 users to access recordings | |
# - DO NOT bother with vfat it is too unreliable | |
# I use the following commands in /etc/rc.local to prepare for running this script | |
# chmod -R a+rw /sys/devices/platform/leds/leds | |
# chmod -R a+rw /sys/class/leds | |
# # get mounted -> is mmcblk03p3 mounted? -> if not run ntfsfix on it -> if that returns ok then mount it | |
# if ! mount | grep -qs "mmcblk0p3 "; then ntfsfix /dev/mmcblk0p3 && mount /dev/mmcblk0p3; fi | |
# # If status led is disk activity to show bootup has been happening now switches to 'heartbeat' | |
# # blink pattern to show bootup has reached this point. The picam.py script takes over control after | |
# echo "heartbeat" > /sys/class/leds/led0/trigger | |
# Settings | |
# Where the recordings go - recommended own partition | |
recordingsFolder = "/var/recordings" | |
# Dont start recording if folder not mounted, fails if not a mount point | |
recordingsFolderCheckMounted = True | |
# path to ffmpeg useful as static binary is a much smaller download than apt-get on minimal raspbian | |
# if using static binary install nscd from apt to avoid name resoultion errors | |
ffmpeg_binary = "/home/picam/bin/ffmpeg" | |
# hostname or ip of rtsp server | |
rtspServerHost="YOURHOST | |
# rtsp username:pass | |
rtspPath="rtsp://YOURUSER:YOURPASS@"+rtspServerHost+":8554/picam" | |
# suffix on clip filenames | |
clipSuffix = "h264" | |
import os | |
import sys | |
import queue | |
import signal | |
import fcntl | |
import time | |
import threading | |
import shutil | |
import subprocess | |
import traceback | |
import json | |
import urllib.request | |
import picamera | |
# Pre-startup create a numbered subfolder in recordings, numbered one larger than whatever is found in there | |
# Numbered folders are required over date time names because unless you have added an RTC to your PI | |
# the date will not be set correctley at boot and if there is no internet connection at all it never will | |
# be. | |
class Stopwatch(): | |
startTime = 0 | |
def __init__(self) : | |
self.reset() | |
def getElapsed(self) : | |
return self.readUptime() - self.startTime | |
def reset(self) : | |
self.startTime = self.readUptime() | |
def readUptime(self) : | |
with open('/proc/uptime', 'r') as f : | |
uptime_seconds = float(f.readline().split()[0]) | |
return uptime_seconds | |
# led functions | |
class LedThread(threading.Thread): | |
led_on_duration = 0.2 | |
led_off_duration = 0.2 | |
def __init__(self, onTime, offTime) : | |
super().__init__() | |
self.setSysTrigger("none") | |
self.stopped = False | |
self.led_on_duration = onTime | |
self.led_off_duration = offTime | |
self.start() | |
def stop(self) : | |
self.stopped = True | |
def setSysTrigger(self, triggerTxt) : | |
f = open("/sys/class/leds/led0/trigger", "w") | |
f.write(triggerTxt) | |
f.close() | |
def newTimings(self, onTime, offTime) : | |
self.led_on_duration = onTime | |
self.led_off_duration = offTime | |
def led_setState(self, v) : | |
f = open("/sys/class/leds/led0/brightness", "w") | |
f.write(str(v)) | |
f.close() | |
def run(self) : | |
while not self.stopped : | |
self.led_setState(1) | |
time.sleep(self.led_on_duration) | |
self.led_setState(0) | |
time.sleep(self.led_off_duration) | |
self.led_setState(1) | |
self.setSysTrigger("heartbeat") | |
class Ffmpeg(): | |
subproc = subprocess.Popen("true") | |
frame = 0 | |
updateTimer = Stopwatch() | |
errque = queue.Queue() | |
errquethread = threading.Thread() | |
killSourceOnStall = False | |
ffmpeg_vid_source = None | |
def __init__(self, cmd, ffmpeg_vid_source = None, killSourceOnStall = False, picam = None, picamSplitterPort = 1) : | |
self.cmd = cmd | |
self.ffmpeg_vid_source = ffmpeg_vid_source | |
self.killSourceOnStall = killSourceOnStall | |
self.picam = picam | |
self.picamSplitterPort = picamSplitterPort | |
def getCmd(self) : | |
return self.cmd | |
def enqueue_output(self, out, queue) : | |
for line in iter(out.readline, b''): | |
queue.put(line.decode('utf-8').strip()) | |
#out.close() | |
def isRunning(self) : | |
return self.subproc.poll() is None | |
def poll(self) : | |
# Monitors source process relationship | |
if self.ffmpeg_vid_source != None : | |
# kill self if source lost/not ready | |
if not self.ffmpeg_vid_source.isRunning() and self.isRunning() : | |
self.stop() | |
return | |
# start subproc if not running | |
if not self.subproc.poll() is None : | |
print("Starting FFMPEG" + " ".join(self.getCmd()) ) | |
self.updateTimer.reset() | |
self.frame = 0 | |
if self.picam != None : | |
try : | |
self.picam.stop_recording(splitter_port=self.picamSplitterPort) | |
except Exception : | |
pass | |
self.subproc = subprocess.Popen(self.getCmd(), stdin=subprocess.PIPE, | |
stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
self.picam.start_recording(self.subproc.stdin, format='h264', bitrate=2000000, splitter_port=self.picamSplitterPort) | |
elif self.ffmpeg_vid_source != None : | |
self.subproc = subprocess.Popen(self.getCmd(), stdin=self.ffmpeg_vid_source.subproc.stdout, | |
stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
else : | |
self.subproc = subprocess.Popen(self.getCmd(), stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
# set up a que for reading stderr | |
self.errque = queue.Queue() | |
self.errquethread = threading.Thread(target=self.enqueue_output, args=(self.subproc.stderr, self.errque)) | |
self.errquethread.daemon = True | |
self.errquethread.start() | |
# check output if is running | |
else : | |
# stderror is set to nonblocking and will raise an exception if no data | |
# loop until this exception occurs to read all data | |
while True : | |
try : | |
input = self.errque.get_nowait() | |
# if a current frame stat is found then compare it to the last | |
# known frame number, if it is different update self.updateTimer | |
# this is used for stall detection below | |
if "frame=" in input : | |
frame = input.split("=")[1] | |
if frame != self.frame : | |
self.frame = frame | |
self.updateTimer.reset() | |
except queue.Empty: | |
# no more output | |
break | |
# stall detection - if frame has not gone up or been reported in 15 sec | |
#print(time.time() - self.lastupdate) | |
#print(self.frame) | |
# assume the ffmpeg process is stuck so kill it | |
if self.updateTimer.getElapsed() > 22 : | |
print("FFMPEG process appears to have stalled") | |
self.stop() | |
self.updateTimer.reset() | |
if self.killSourceOnStall : | |
self.ffmpeg_vid_source.stop() | |
def stop(self) : | |
if self.isRunning() : | |
print("Stopping FFMPEG " + " ".join(self.getCmd())) | |
if not self.picam is None : | |
try : | |
self.picam.stop_recording(splitter_port=self.picamSplitterPort) | |
except Exception : | |
pass | |
self.subproc.kill() | |
self.subproc.wait() | |
class Recorder() : | |
recordings_subFolder = None | |
clipCounter = 1 | |
currentClipStartTime = Stopwatch() | |
encoderStateOK = False | |
def __init__(self, picam, recordingsFolder, recordingsFolderCheckMounted = True, picamSplitterPort = 1) : | |
self.picam = picam | |
self.recordingsFolder = recordingsFolder | |
self.recordingsFolderCheckMounted = recordingsFolderCheckMounted | |
self.picamSplitterPort = picamSplitterPort | |
def poll(self) : | |
# ensure recordings folder ok | |
if not self.recordingsFolderMountedAndReady(create = True) : | |
print("Recordings folder not ready") | |
self.stop() | |
self.updateTimer.reset() | |
self.frame = 0 | |
self.encoderStateOK = False | |
return | |
# ensure recording | |
if not self.picam.recording : | |
print("Staring recording") | |
self.picam.start_recording( | |
output = self.recordings_subFolder + f"/{self.clipCounter:09d}." + clipSuffix, | |
format = 'h264', | |
resize = None, | |
splitter_port = self.picamSplitterPort | |
) | |
self.encoderStateOK = False | |
self.currentClipStartTime.reset() | |
return | |
# query encoder for errors | |
try : | |
self.picam.wait_recording(timeout=0, splitter_port=self.picamSplitterPort) | |
self.encoderStateOK = True | |
except : | |
self.encoderStateOK = False | |
return | |
# split recordings at aprox 30s intervals | |
if self.currentClipStartTime.getElapsed() >= 30 : | |
self.clipCounter = self.clipCounter + 1 | |
self.currentClipStartTime.reset() | |
self.picam.split_recording( | |
output = self.recordings_subFolder + f"/{self.clipCounter:09d}." + clipSuffix, | |
splitter_port = self.picamSplitterPort | |
) | |
self.cleanupDiskSpace() | |
def stop(self) : | |
if self.picam.recording : | |
self.picam.stop_recording(self.picamSplitterPort) | |
self.encoderStateOK = False | |
def recordingsFolderMountedAndReady(self, create = True) : | |
if not create : | |
if self.recordings_subFolder is None : | |
return False | |
return os.path.exists(self.recordings_subFolder) | |
if not self.recordings_subFolder is None : | |
if os.path.exists(self.recordings_subFolder) : | |
return True | |
if self.recordingsFolderCheckMounted: | |
if not self.recordingsFolder+" " in subprocess.check_output(['mount']).decode("utf-8") : | |
return False | |
largest = 0 | |
for root,dirs,files in os.walk(self.recordingsFolder) : | |
for name in dirs : | |
if name.isdigit() : | |
if largest < int(name) : | |
largest = int(name) | |
# make a new folder for output | |
largest += 1 | |
self.recordings_subFolder = recordingsFolder + "/" + f"{largest:09d}" | |
os.mkdir(self.recordings_subFolder) | |
if not os.path.exists(self.recordings_subFolder) : | |
print("Error creating " + self.recordings_subFolder) | |
return False | |
print("Begin new recording " + self.recordings_subFolder) | |
return True | |
def cleanupDiskSpace(self) : | |
if not self.recordingsFolderMountedAndReady(create = False) : | |
return | |
# get free space in MB | |
total, used, free = shutil.disk_usage(self.recordingsFolder) | |
freeMB = int(0.000001 * free) | |
# if free space is low | |
if freeMB < 50 : | |
# find oldest mp4 | |
mp4files = [] | |
for dirpath, subdirs, files in os.walk(self.recordingsFolder) : | |
for x in files : | |
if x.endswith("."+clipSuffix) : | |
mp4files.append(os.path.join(dirpath, x)) | |
mp4files.sort() | |
# delete oldest mp4 | |
if len(mp4files) > 0 : | |
os.remove(mp4files[0]) | |
# clear empty folders | |
walk = list(os.walk(self.recordingsFolder)) | |
for path, _, _ in walk[::-1] : | |
if len(os.listdir(path)) == 0 : | |
if not self.recordingsFolder in path and not path in self.recordingsFolder: | |
os.rmdir(path) | |
class FfmpegBroadcast(Ffmpeg) : | |
def __init__(self, picam, hostname, rtspPath, picamSplitterPort = 2) : | |
self.picam = picam | |
self.hostname = hostname | |
self.picamSplitterPort = picamSplitterPort | |
self.rtspPath = rtspPath | |
def serverReachable(self) : | |
return True if os.system("ping -c 1 "+self.hostname+" >/dev/null") is 0 else False | |
def poll(self) : | |
# skip parent poll if ffmpeg is not already running AND | |
# the host is not pingable | |
if not self.isRunning() : | |
if not self.serverReachable() : | |
self.stop() | |
self.updateTimer.reset() | |
self.frame = 0 | |
return | |
Ffmpeg.poll(self) | |
def getCmd(self) : | |
return [ ffmpeg_binary, | |
"-v", "error", "-progress", "pipe:2", "-stats_period", "4", | |
"-re", "-i", "-", | |
"-c:v", "copy", "-an", "-f", "rtsp", | |
"-rtsp_transport", "tcp", rtspPath, | |
] | |
# Setup | |
thread_led = LedThread(0.2,0.2) | |
print("Camera init") | |
camera = picamera.PiCamera() | |
camera.brightness = 75 | |
camera.contrast = 75 | |
camera.drc_strength = "high" # dynamic range compression off/low/medium/high default=off | |
camera.exposure_mode = "nightpreview" | |
camera.exposure_compensation = 25 # -25 to 25 higher=brighter | |
camera.meter_mode = "backlit" | |
camera.resolution = (1296,972) | |
#camera.framerate_range = (1,24) | |
camera.iso = 1600 | |
#camera.sensor_mode = 4 | |
#camera.color_effects = (128, 128) | |
camera.video_stabilization = True | |
print("Camera init complete") | |
print("Init recorder") | |
recorder = Recorder(camera, recordingsFolder, recordingsFolderCheckMounted, picamSplitterPort = 1) | |
print("Init broadcaster") | |
ffmpeg_broadcast = FfmpegBroadcast(camera, rtspServerHost, rtspPath, picamSplitterPort = 2) | |
def shutdownHandler(signal, frame) : | |
print("Shutting down") | |
try : | |
thread_led.stop() | |
thread_led.join() | |
except Exception : | |
pass | |
try : | |
recorder.stop() | |
except Exception : | |
pass | |
try : | |
ffmpeg_broadcast.stop() | |
except Exception : | |
pass | |
try : | |
camera.close() | |
except Exception : | |
pass | |
sys.exit(0) | |
signal.signal(signal.SIGINT, shutdownHandler) | |
signal.signal(signal.SIGTERM, shutdownHandler) | |
# Main loop | |
while True : | |
time.sleep(1) | |
recorder.poll() | |
ffmpeg_broadcast.poll() | |
if recorder.encoderStateOK and int(ffmpeg_broadcast.frame) > 30 : | |
thread_led.newTimings(0.1,3) | |
elif recorder.encoderStateOK and int(ffmpeg_broadcast.frame) < 30 : | |
thread_led.newTimings(0.2,1) | |
else : | |
thread_led.newTimings(0.2,0.2) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment