Skip to content

Instantly share code, notes, and snippets.

@mwvent
Last active February 10, 2022 00:57
Show Gist options
  • Save mwvent/53775ab7af90edfbc71d54c2b2c1d1cb to your computer and use it in GitHub Desktop.
Save mwvent/53775ab7af90edfbc71d54c2b2c1d1cb to your computer and use it in GitHub Desktop.
PI Zero Bicycle Dashcam Python Service
#!/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