#!/usr/bin/python3

# A surveillance camera script based on Picamera2.
# When the camera detects motion, a video is recorded.
# Optionally, a live video stream is accessible over http.

# Picamera2 Manual:
# https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf
# Picamera2 Source:
# https://github.com/raspberrypi/picamera2
# Picamera2 Examples:
# https://github.com/raspberrypi/picamera2/blob/main/examples/capture_motion.py
# https://github.com/raspberrypi/picamera2/blob/main/examples/mjpeg_server_2.py

# To automatically start the surveillance camera script on boot,
# a systemd service can be defined:
# /etc/systemd/system/picam2_surveillance.service
#
# [Unit]
# Description=Picamera2 Surveillance Camera
#
# [Service]
# ExecStart=/usr/local/bin/picam2_surveillance.py
#
# [Install]
# WantedBy=multi-user.target
#

__author__ = "Gernot Walzl"
__date__ = "2023-12-29"

import os
import sys
import signal
import base64
from datetime import datetime
from time import time, sleep
from threading import Thread, Lock, Condition
import subprocess
import io
from configparser import ConfigParser
import logging
from logging.handlers import TimedRotatingFileHandler
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import cv2
import numpy as np
import libcamera
from picamera2 import Picamera2, MappedArray
from picamera2.encoders import H264Encoder, MJPEGEncoder
from picamera2.outputs import FileOutput


def load_config():
    config = ConfigParser()
    config.add_section('camera')
    config.set('camera', 'width', '960')
    config.set('camera', 'height', '720')
    config.set('camera', 'fps', '15.0')
    config.set('camera', 'mode', '1')
    config.set('camera', 'hflip', '0')
    config.set('camera', 'vflip', '0')
    config.set('camera', 'apply_scaler_crop', 'no')
    config.set('camera', 'scaler_crop_x', '0')
    config.set('camera', 'scaler_crop_y', '2')
    config.set('camera', 'scaler_crop_w', '3280')
    config.set('camera', 'scaler_crop_h', '2460')
    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', 'convert_mp4', 'yes')
    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/picam2_surveillance.ini'))
    return config


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


class FfmpegThread(Thread):

    def __init__(self, fps, filepath):
        super().__init__()
        self._fps = fps
        self._filepath = filepath

    def run(self):
        command = ['ffmpeg', '-loglevel', 'error', '-y']
        video_input = ['-r', str(self._fps), '-i', self._filepath]
        video_codec = ['-c:v', 'copy']
        output = ['-movflags', 'faststart', self._filepath[:-4]+'mp4']
        command += video_input + video_codec + output
        ffmpeg = subprocess.Popen(command)
        if ffmpeg.wait() == 0:
            os.remove(self._filepath)


class StreamingOutput(io.BufferedIOBase):

    def __init__(self):
        super().__init__()
        self.frame = None
        self.condition = Condition()

    def write(self, buf):
        with self.condition:
            self.frame = buf
            self.condition.notify_all()


class SurveillanceCamera:

    def __init__(self, config):
        self._logger = logging.getLogger(self.__class__.__name__)

        self._width = config.getint('camera', 'width')
        self._height = config.getint('camera', 'height')
        self._fps = config.getfloat('camera', 'fps')
        self._sensor_mode = config.getint('camera', 'mode')
        self._hflip = config.getint('camera', 'hflip')
        self._vflip = config.getint('camera', 'vflip')

        self._scaler_crop = None
        if config.getboolean('camera', 'apply_scaler_crop'):
            self._scaler_crop = (
                config.getint('camera', 'scaler_crop_x'),
                config.getint('camera', 'scaler_crop_y'),
                config.getint('camera', 'scaler_crop_w'),
                config.getint('camera', 'scaler_crop_h'))

        self._path_output = config.get('output', 'path_rec')
        self._min_length_video = config.getfloat('output', 'min_length_video')
        self._convert_mp4 = config.getboolean('output', 'convert_mp4')

        self._picam2 = Picamera2()
        self._capturing = False
        self._encoder_recording = None
        self._filepath_recording = None
        self._encoder_serving = None
        self._output = None
        self._lock_serving = Lock()
        self._counter_serving = 0

    @staticmethod
    def _annotate_timestamp(request):
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        position = (8, 16)
        font = cv2.FONT_HERSHEY_PLAIN
        color = (255, 255, 255)
        with MappedArray(request, "main") as frame:
            cv2.putText(frame.array, timestamp, position, font, 1.0, color)

    def _has_frame_changed(self, frame_1, frame_2, min_changed_pixels):
        frame_diff = np.abs(np.subtract(frame_1.astype(int), frame_2))
        num_changed_pixels = (frame_diff > 32).sum()
        return num_changed_pixels > min_changed_pixels

    def start_recording(self):
        dt_now = datetime.now()
        dirname = dt_now.strftime('%Y-%m-%d')
        basename = dt_now.strftime('%Y-%m-%d_%H%M%S')
        if not os.path.exists(self._path_output + dirname):
            os.makedirs(self._path_output + dirname)
        self._filepath_recording = (
            self._path_output + dirname + '/' + basename + '.h264')
        self._logger.info("Start recording to %s", self._filepath_recording)
        self._encoder_recording = H264Encoder()
        self._encoder_recording.framerate = self._fps
        # The FfmpegOutput creates a jitter because the timestamps of the
        # video frames are not forwarded. Ffmpeg stamps the video frames
        # as it gets them. Unfortunately, ffmpeg takes a second to start.
        # output = FfmpegOutput(filepath)
        output = FileOutput(self._filepath_recording)
        self._picam2.start_encoder(self._encoder_recording, output)

    def stop_recording(self):
        self._picam2.stop_encoder(self._encoder_recording)
        self._encoder_recording = None
        self._logger.info("Recording stopped")
        if self._convert_mp4:
            self._logger.info("Converting %s", self._filepath_recording)
            FfmpegThread(self._fps, self._filepath_recording).start()

    def start_capturing(self):
        self._logger.info(
            "Selected sensor mode: %s",
            self._picam2.sensor_modes[self._sensor_mode])
        config = self._picam2.create_video_configuration(

            # Picamera2 Manual - Appendix A: Pixel and image formats
            main={
                'format': 'BGR888',
                'size': (self._width, self._height)
            },
            lores={
                'format': 'YUV420',
                'size': (int(self._width/4), int(self._height/4))
            },

            # Although the IDs of the sensor modes are different in picamera,
            # there is good documentation to get an idea about sensor modes:
            # https://picamera.readthedocs.io/en/latest/fov.html#sensor-modes
            raw=self._picam2.sensor_modes[self._sensor_mode],

            # Picamera2 Manual - Appendix B: Camera configuration parameters
            transform=libcamera.Transform(hflip=self._hflip, vflip=self._vflip),

            # Picamera2 Manual - Appendix C: Camera controls
            controls={
                'FrameDurationLimits': (int(1e6/self._fps), int(1e6/self._fps))
            }
        )
        if self._scaler_crop is not None:
            config['controls']['ScalerCrop'] = self._scaler_crop
        self._logger.info("Used config: %s", config)
        self._picam2.configure(config)
        self._picam2.post_callback = SurveillanceCamera._annotate_timestamp

        self._logger.info("Start capturing")
        self._capturing = True
        self._picam2.start()

        frame_curr = None
        frame_prev = None
        time_last_change = None
        while self._capturing:
            (lores_w, lores_h) = self._picam2.video_configuration.lores.size
            frame_curr = self._picam2.capture_buffer('lores')[:lores_w*lores_h]
            frame_curr = frame_curr.reshape(lores_h, lores_w)
            if frame_prev is not None:
                if self._encoder_recording is None:
                    if self._has_frame_changed(frame_prev, frame_curr, 32):
                        time_last_change = time()
                        self.start_recording()
                else:
                    if self._has_frame_changed(frame_prev, frame_curr, 8):
                        time_last_change = time()
                    elif time_last_change + self._min_length_video < time():
                        self.stop_recording()
            frame_prev = frame_curr
        if self._encoder_recording is not None:
            self.stop_recording()
        self._picam2.stop()
        self._logger.info("Capturing stopped")

    def stop_capturing(self):
        self._capturing = False

    def start_serving(self):
        self._logger.info("Start serving")
        with self._lock_serving:
            if self._counter_serving == 0:
                self._logger.info("Start MJPEG encoder")
                self._encoder_serving = MJPEGEncoder()
                self._encoder_serving.framerate = self._fps
                self._output = StreamingOutput()
                self._picam2.start_encoder(
                    self._encoder_serving, FileOutput(self._output))
            self._counter_serving += 1
        return self._output

    def stop_serving(self):
        with self._lock_serving:
            self._counter_serving -= 1
            if self._counter_serving == 0:
                self._picam2.stop_encoder(self._encoder_serving)
                self._encoder_serving = None
                self._output = None
                self._logger.info("MJPEG encoder stopped")
        self._logger.info("Serving stopped")


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_jpeg(self, output):
        with output.condition:
            output.condition.wait()
            self.send_header('Content-Type', 'image/jpeg')
            self.send_header('Content-Length', len(output.frame))
            self.end_headers()
            self.wfile.write(output.frame)

    def do_GET(self):
        if self.path == "/cam.jpg":
            if self.check_auth():
                output = self.server.survcam.start_serving()
                try:
                    self.send_response(200)
                    self.send_jpeg(output)
                except Exception as err:
                    self.logger().warning(
                        "Exception while serving client %s: %s",
                        self.client_address, str(err))
                finally:
                    self.server.survcam.stop_serving()
                    output = None
        elif self.path == "/cam.mjpg":
            if self.check_auth():
                output = self.server.survcam.start_serving()
                try:
                    self.send_response(200)
                    self.send_header(
                        'Content-Type',
                        'multipart/x-mixed-replace; boundary=FRAME')
                    self.end_headers()
                    while not self.wfile.closed:
                        self.wfile.write(b"--FRAME\r\n")
                        self.send_jpeg(output)
                        self.wfile.write(b"\r\n")
                        self.wfile.flush()
                except Exception as err:
                    self.logger().warning(
                        "Exception while serving client %s: %s",
                        self.client_address, str(err))
                finally:
                    self.server.survcam.stop_serving()
                    output = None
        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 HTTPServerThread(Thread):

    def __init__(self, config, survcam):
        super().__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.server = ThreadingHTTPServer(
            ('', 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 = []
        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):
        super().__init__()
        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__)

    def _handle_signal(self, signum, frame):
        self._logger.info("Handling signal %s", signum)
        self._survcam.stop_capturing()

    def register_signal_handlers(self):
        signal.signal(signal.SIGINT, self._handle_signal)   # Ctrl-C
        signal.signal(signal.SIGTERM, self._handle_signal)  # kill


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)
    sighandler.register_signal_handlers()
    survcam.start_capturing()
    if storagecleanerthread:
        storagecleanerthread.stop_cleaning()
    if httpthread:
        httpthread.stop_serving()


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