#!/usr/bin/python3

# A surveillance camera script for the raspberry pi camera.
# When the camera detects motion, a video is recorded.
# Optionally, a live video stream is accessible over http.

# On a default Raspian installation, the package 'python3-opencv' is required:
# apt-get install python3-opencv

# Known Issues
#
# 2019-09-24 (stretch)
# The automatic white balance (awb) algorithm was updated in firmware
# with kernel 4.19. This caused spontaneous purple distortions
# when recording dark scenes. The old algorithm is still available and
# can be activated with the following command:
# vcdbg set awb_mode 0
# Alternatively, the latest 4.14 kernel can be used:
# BRANCH='stable' rpi-update 45a2e771e7272781e62ae92322734c2b90e0268a
#
# 2019-05-24 (stretch)
# self.video_writer.write(frame) has a memory leak.
# This script will exit after config['output']['max_frames_written']
# has been reached.
#
# 2017-05-24 (jessie)
# There is an issue with cv2.VideoWriter (or the MJPG codec).
# At some point, all newly created files have size zero after a frame
# should have been written.
#
# As a workaround for the issues above,
# the following shell script can be used to restart this python script:
#
# vcdbg set awb_mode 0
# RESTART=1
# while [ "$RESTART" -ne 0 ]; do
#   ./cam_surveillance.py
#   RESTART=$?
# done
#

__author__ = "Gernot Walzl"
__date__ = "2021-07-04"

import os
import sys
import signal
import base64
import logging
from logging.handlers import TimedRotatingFileHandler
from time import time, sleep
from datetime import datetime
from threading import Thread
from picamera import PiCamera
from picamera.array import PiRGBArray
import cv2
if sys.version_info.major == 2:
    from ConfigParser import ConfigParser
    from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
else:
    from configparser import ConfigParser
    from http.server import BaseHTTPRequestHandler, HTTPServer


def load_config():
    config = ConfigParser()
    config.add_section('camera')
    config.set('camera', 'width', '960')
    config.set('camera', 'height', '720')
    config.set('camera', 'fps', '5')
    config.set('camera', 'mode', '0')
    config.set('camera', 'rotation', '0')
    config.set('camera', 'zoom_x', '0.0')
    config.set('camera', 'zoom_y', '0.0')
    config.set('camera', 'zoom_w', '1.0')
    config.set('camera', 'zoom_h', '1.0')
    config.add_section('output')
    config.set('output', 'path_rec', os.path.expanduser('~/cam/rec/'))
    config.set('output', 'path_log', os.path.expanduser('~/cam/log/'))
    config.set('output', 'max_used_space', str(32 * 1024**3))
    config.set('output', 'min_length_video', '5.0')
    config.set('output', 'max_frames_written', '9000')
    config.add_section('server')
    config.set('server', 'port', '8000')
    config.set('server', 'user', 'user')
    config.set('server', 'pass', 'pass')
    config.read(os.path.expanduser('~/.config/cam_surveillance.ini'))
    return config


def init_logging(config):
    logger = logging.getLogger()
    logger.setLevel(logging.INFO)
    loghandler = TimedRotatingFileHandler(
        config.get('output', 'path_log') + 'cam_surveillance.log', 'midnight')
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    loghandler.setFormatter(formatter)
    logger.addHandler(loghandler)


class SurveillanceCamera:

    def __init__(self, config):
        self.config = config
        self.logger = logging.getLogger(self.__class__.__name__)
        self.path_output = config.get('output', 'path_rec')
        self.min_length_video = config.getfloat('output', 'min_length_video')
        self.max_frames_written = config.getint('output', 'max_frames_written')

        self.camera = None
        self.capturing = False
        self.capturing_retval = 0
        self.frame_curr_bgr = None
        self.frame_curr_annotated = None
        self.frame_curr_gray = None
        self.frame_prev_bgr = None
        self.frame_prev_annotated = None
        self.frame_prev_gray = None
        self.time_last_change = 0.0
        self.video_writer = None
        self.recording = False
        self.filenumber = 0
        self.num_frames_written = 0

    def has_frame_changed(self, frame_gray_1, frame_gray_2, min_changed_pixels):
        frame_diff_gray = cv2.absdiff(frame_gray_1, frame_gray_2)
        frame_thresh = cv2.threshold(
            frame_diff_gray, 32, 255, cv2.THRESH_BINARY)[1]
        num_diff_pixels = frame_thresh.sum() / 255
        return num_diff_pixels >= min_changed_pixels

    def annotate_frame(self, frame_bgr):
        timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        frame = frame_bgr.copy()
        position = (8, 16)
        font = cv2.FONT_HERSHEY_PLAIN
        color = (255, 255, 255)
        cv2.putText(frame, timestamp, position, font, 1.0, color)
        return frame

    def write_frame(self, frame_bgr):
        self.video_writer.write(frame_bgr)
        self.num_frames_written += 1

    def fourcc(self, c1, c2, c3, c4):
        if hasattr(cv2, 'cv'):
            return cv2.cv.FOURCC(c1, c2, c3, c4)
        else:
            return cv2.VideoWriter_fourcc(c1, c2, c3, c4)

    def start_recording(self):
        self.filenumber += 1
        dt_now = datetime.now()
        dirname = dt_now.strftime('%Y-%m-%d')
        basename = dt_now.strftime('%Y-%m-%d_%H%M%S')
        basename += '_' + str(self.filenumber).zfill(4)
        filepath = self.path_output + dirname + '/' + basename + '.avi'
        fourcc = self.fourcc('M', 'J', 'P', 'G')
        if not os.path.exists(self.path_output + dirname):
            os.makedirs(self.path_output + dirname)
        self.logger.info("Start recording to %s", filepath)
        self.recording = True
        self.logger.debug(
            "fourcc=%s, fps=%s, res=%s",
            fourcc, self.camera.framerate, self.camera.resolution)
        self.video_writer = cv2.VideoWriter(
            filepath, fourcc, float(self.camera.framerate),
            self.camera.resolution)
        self.logger.debug(
            "video_writer=%s isOpened=%s",
            self.video_writer, self.video_writer.isOpened())
        self.logger.debug("frame_shape=%s", self.frame_prev_bgr.shape)
        self.write_frame(self.frame_prev_annotated)
        # self.camera.start_recording(filepath)
        # if os.path.getsize(filepath) == 0:
        #     self.logger.error(
        #         "Unable to encode frames into file (filesize=0)")
        #     self.stop_capturing(1)

    def stop_recording(self):
        if self.video_writer:
            self.video_writer.release()
            self.video_writer = None
        # self.camera.stop_recording()
        self.recording = False
        self.logger.info("Recording stopped")

    def handle_frame(self, frame):
        self.frame_curr_bgr = frame
        self.frame_curr_annotated = self.annotate_frame(frame)
        self.frame_curr_gray = cv2.cvtColor(
            self.frame_curr_bgr, cv2.COLOR_BGR2GRAY)
        # resizing is faster than cv2.GaussianBlur(frame_curr_gray, (33, 33), 0)
        self.frame_curr_gray = cv2.resize(self.frame_curr_gray, (160, 120))
        if self.frame_prev_gray is not None:
            if self.recording:
                if self.has_frame_changed(
                        self.frame_prev_gray, self.frame_curr_gray, 8):
                    self.time_last_change = time()
            else:
                if self.has_frame_changed(
                        self.frame_prev_gray, self.frame_curr_gray, 32):
                    self.time_last_change = time()
                    self.start_recording()
        if self.recording:
            self.write_frame(self.frame_curr_annotated)
            if self.time_last_change + self.min_length_video < time():
                self.stop_recording()
        self.frame_prev_bgr = self.frame_curr_bgr
        self.frame_prev_annotated = self.frame_curr_annotated
        self.frame_prev_gray = self.frame_curr_gray

    def start_capturing(self):
        try:
            with PiCamera(
                    resolution=(
                        self.config.getint('camera', 'width'),
                        self.config.getint('camera', 'height')),
                    framerate=self.config.getint('camera', 'fps'),
                    sensor_mode=self.config.getint('camera', 'mode')) as self.camera:
                self.camera.rotation = self.config.getint('camera', 'rotation')
                self.camera.zoom = (
                    self.config.getfloat('camera', 'zoom_x'),
                    self.config.getfloat('camera', 'zoom_y'),
                    self.config.getfloat('camera', 'zoom_w'),
                    self.config.getfloat('camera', 'zoom_h'))
                with PiRGBArray(self.camera) as output:
                    self.logger.info("Start capturing")
                    self.capturing = True
                    for _ in self.camera.capture_continuous(
                            output, 'bgr', use_video_port=True):
                        self.handle_frame(output.array)
                        output.seek(0)
                        output.truncate()
                        if not self.capturing:
                            break
                        if not self.recording:
                            if self.num_frames_written >= self.max_frames_written:
                                self.logger.info(
                                    "Maximum number of frames written " +
                                    "(num_frames_written=%s)",
                                    self.num_frames_written)
                                self.stop_capturing(1)
        except Exception as err:
            self.logger.error(err)
        finally:
            if self.recording:
                self.stop_recording()
            self.logger.info(
                "Capturing stopped (retval=%s)", self.capturing_retval)
        return self.capturing_retval

    def stop_capturing(self, retval=0):
        self.capturing_retval = retval
        self.capturing = False

    def get_frame_annotated(self):
        result = None
        if self.capturing:
            result = self.frame_curr_annotated
        return result


class CameraHTTPRequestHandler(BaseHTTPRequestHandler):

    def logger(self):
        return logging.getLogger('HTTPRequestHandler')

    def check_auth(self):
        result = False
        if (self.server.auth is None or
                self.server.auth == self.headers.get('authorization')):
            result = True
        else:
            self.send_response(401)
            self.send_header('WWW-Authenticate', 'Basic')
            self.end_headers()
        return result

    def send_jpg(self):
        result = False
        frame = self.server.survcam.get_frame_annotated()
        if frame is None:
            self.logger().warning("Did not get a frame to send")
        else:
            ret, jpg = cv2.imencode('.jpg', frame)
            self.send_header('Content-type', 'image/jpeg')
            self.send_header('Content-length', str(jpg.size))
            self.end_headers()
            self.wfile.write(jpg.tostring())
            result = True
        return result

    def do_GET(self):
        if self.path == "/cam.jpg":
            if self.check_auth():
                self.send_response(200)
                self.send_jpg()
        elif self.path == "/cam.mjpg":
            if self.check_auth():
                self.send_response(200)
                self.send_header(
                    'Content-type',
                    'multipart/x-mixed-replace; boundary=jpgboundary')
                self.end_headers()
                while not self.wfile.closed:
                    self.wfile.write(b"--jpgboundary\n")
                    if not self.send_jpg():
                        break
                    self.wfile.write(b"\n")
                    self.wfile.flush()
                    sleep(0.2)
        else:
            self.send_error(404)

    def log_error(self, format, *args):
        self.logger().error("%s - %s" % (self.address_string(), format % args))

    def log_message(self, format, *args):
        self.logger().info("%s - %s" % (self.address_string(), format % args))


class CameraHTTPServer(HTTPServer):

    def handle_error(self, request, client_address):
        logging.getLogger('HTTPServer').warning(
            "Exception happened during processing of request from %s",
            client_address)


class HTTPServerThread(Thread):

    def __init__(self, config, survcam):
        Thread.__init__(self)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.server = CameraHTTPServer(
            ('', config.getint('server', 'port')),
            CameraHTTPRequestHandler)
        self.server.survcam = survcam
        self.server.auth = None
        if config.get('server', 'user') and config.get('server', 'pass'):
            str_auth = config.get('server', 'user') + ':' + config.get('server', 'pass')
            self.server.auth = 'Basic ' + base64.b64encode(str_auth.encode()).decode()

    def run(self):
        self.logger.info(
            "Starting HTTP server on port %s", self.server.server_port)
        self.server.serve_forever()

    def stop_serving(self):
        self.logger.info("Stopping HTTP server")
        self.server.shutdown()


class StorageCleaner:

    def __init__(self, path, max_used_space):
        self.logger = logging.getLogger(self.__class__.__name__)
        self.path = path
        self.max_used_space = max_used_space

    def get_filelist(self):
        filelist = list()
        for dirpath, _, filenames in os.walk(self.path):
            for filename in filenames:
                filepath = os.path.join(dirpath, filename)
                filemtime = os.path.getmtime(filepath)
                filesize = os.path.getsize(filepath)
                fileinfo = (filemtime, filesize, filepath)
                filelist.append(fileinfo)
        filelist.sort()
        return filelist

    def remove_oldest_files(self):
        filelist = self.get_filelist()
        sumfilesizes = sum(filesize for _, filesize, _ in filelist)
        for fileinfo in filelist:
            if sumfilesizes > self.max_used_space:
                filepath = fileinfo[2]
                self.logger.info("Removing %s", filepath)
                os.remove(filepath)
                sumfilesizes -= fileinfo[1]
            else:
                break

    def remove_empty_dirs(self):
        for dirpath, dirnames, filenames in os.walk(self.path):
            if (not dirnames) and (not filenames):
                self.logger.info("Removing empty dir %s", dirpath)
                os.rmdir(dirpath)


class StorageCleanerThread(Thread):

    def __init__(self, config):
        Thread.__init__(self)
        self.cleaning = False
        self.cleaner = StorageCleaner(
            config.get('output', 'path_rec'),
            config.getint('output', 'max_used_space'))

    def run(self):
        self.cleaning = True
        while self.cleaning:
            self.cleaner.remove_oldest_files()
            self.cleaner.remove_empty_dirs()
            for _ in range(60):
                sleep(1)
                if not self.cleaning:
                    break

    def stop_cleaning(self):
        self.cleaning = False


class SignalHandler:

    def __init__(self, survcam):
        self.survcam = survcam
        self.logger = logging.getLogger(self.__class__.__name__)
        signal.signal(signal.SIGINT, self.handle_signal)   # Ctrl-C
        signal.signal(signal.SIGTERM, self.handle_signal)  # kill

    def handle_signal(self, signum, frame):
        self.logger.info("Handling signal %s", signum)
        self.survcam.stop_capturing()


def main():
    config = load_config()
    if not os.path.exists(config.get('output', 'path_rec')):
        os.makedirs(config.get('output', 'path_rec'))
    if not os.path.exists(config.get('output', 'path_log')):
        os.makedirs(config.get('output', 'path_log'))
    init_logging(config)
    logging.info("Logging initialized")
    survcam = SurveillanceCamera(config)
    httpthread = None
    if config.getint('server', 'port') > 0:
        httpthread = HTTPServerThread(config, survcam)
        httpthread.start()
    storagecleanerthread = None
    if config.getint('output', 'max_used_space') > 0:
        storagecleanerthread = StorageCleanerThread(config)
        storagecleanerthread.start()
    sighandler = SignalHandler(survcam)
    exit_status = survcam.start_capturing()
    if storagecleanerthread:
        storagecleanerthread.stop_cleaning()
    if httpthread:
        httpthread.stop_serving()
    logging.info("Exit with status %s", exit_status)
    return exit_status


if __name__ == '__main__':
    sys.exit(main())