Surveillance with Raspberry Pi and Docker: a complete Step-by-Step tutorial

Table of Contents

Introduction

Surveillance with Raspberry Pi and Docker: a complete step-by-step tutorial. If you are looking for a guide to building a DIY surveillance system, you are in the right place. This comprehensive tutorial will walk you through the setup of a Raspberry Pi-based system—an affordable and versatile solution, perfect for home automation and security projects. We will use modern technologies like Docker, Motion , FastAPI and React, to build a scalable, easy-to-manage system that is accessible to both experts and beginners.

The heart of the system is the Raspberry Pi, which with the support of Docker containerizes the backend and frontend, making installation and maintenance extremely simple. Thanks to Motion, the camera will detect movement and capture images or videos, storing them locally. A user interface built with React will allow you to view and manage the acquired material, with functions such as pagination and thumbnail preview. All of this is coordinated by a backend written in Python with FastAPI, which manages the REST APIs for the entire system, including notifications via Telegram, Gmail and Pushover.

Additionally, if you want to access your video surveillance system even when you are away from home, you can set up a secure VPN connection with WireGuard. This will allow you to connect to the Raspberry Pi as if you were on the same local network, providing secure remote access without exposing your network to the Internet. However, this setup requires some specific technical conditions, such as having an accessible public IP and the ability to open ports on your router. In this tutorial, I will address these aspects and provide a step-by-step guide to check if your environment is compatible with this solution.

Whether you want to monitor your home, office or small workshop, this system is a complete and highly customizable solution. In just a few steps, you can have a robust and flexible video surveillance system that takes full advantage of the potential of the Raspberry Pi.

👉 If you are looking for alternative solutions, also take a look at our ESP32 video surveillance projects, including a compact version with Telegram and a motorized WiFi camera controllable via the web.

What our video surveillance system can do

The video surveillance system that we will guide you to create offers a practical and complete solution for monitoring any environment, be it your home, office or a small laboratory. Using a Raspberry Pi as the technological heart, this system combines versatility and simplicity to meet various security needs. Here is a summary of the main features that you will have at your disposal:

  • Photo and video capture: thanks to the integration with Motion, the system automatically detects motion and records high-quality photos or videos, which are saved locally to give you maximum control over your data.
  • Intuitive viewing: a modern graphical interface lets you easily browse photos and videos, organized in a grid with thumbnails. The pagination function lets you quickly find the files you need, even when the archive is very large.
  • Real-time notifications: receive instant notifications directly to your smartphone or via email whenever a new event is recorded. The system supports Telegram, Gmail and Pushover, giving you the freedom to choose the notification method you prefer.
  • Complete user management: with the administrator role, you can create, edit, and remove accounts for other users, ensuring secure and controlled access to the system. Regular users can easily log in and customize their own password for added security.
  • Customizable configurations: everything from notifications to recording mode (photo or video) can be customized directly from the web interface, without having to intervene on the configuration files manually.
  • Easy to install and maintain: thanks to Docker, the entire system is containerized, making installation and updates very simple, even for those who are not tech experts.
  • Simple and automated installation: the system installation is practically automatic. Thanks to the automation offered by Docker, you will only need to launch two scripts to configure and start the complete system, without having to worry about complex configurations.

With all these features, this system represents a complete and flexible solution for video surveillance. By following this tutorial, you can create your system step by step, adapting it to your specific needs.

What you will learn by following this tutorial

This article is not just about guiding you step by step through the creation of a video surveillance system: its real goal is to help you discover and experiment with a series of fundamental technologies and concepts in the world of IT and home automation. Here’s what you’ll learn by following this project:

  • Docker and containerization: you will understand how Docker simplifies application management, allowing you to create a modular and easily deployable system. You will learn how to use containers to separate the backend and the frontend and how to leverage Docker Compose to orchestrate everything with a single command.
  • REST API management: you will explore how a modern backend works thanks to FastAPI, discovering how REST APIs allow the frontend to communicate with the backend to manage photos, videos and configurations.
  • Web interface development: thanks to the use of React, you will learn how to design and build a dynamic user interface, with features such as thumbnail viewing, pagination and configuration management.
  • Custom notifications: you will configure and use notification systems such as Telegram, Gmail and Pushover to receive real-time updates, integrating them with your security system.
  • Image and video processing: you will discover how to generate thumbnails for photos and videos, using libraries such as Pillow and tools such as FFmpeg to extract frames from videos.
  • User and security management: you will learn how to implement an authentication system with JWT tokens, differentiate user roles, and ensure that access is secure and personalized.
  • Dynamic configurations: you will understand how to make a system easily configurable, using configuration files and APIs to change parameters without having to manually access the server files.
  • Debugging and experimentation: this project will encourage you to make changes and try out new ideas. You will be able to experiment by adding features, improving the interface, or integrating new technologies, putting your acquired skills to the test.

Whether you’re a tech enthusiast or a curious beginner, this tutorial will give you practical skills that you can reuse in many other projects. Your creativity is the only limit!

Backend and frontend: the two souls of the system

A modern IT system, like the one we are building for video surveillance, is composed of two main components: the backend (BE) and the frontend (FE). These two elements work together to provide robust functionality and an intuitive user experience.

What is the backend (BE)?

The backend is the “behind the scenes” part of the system. It is the engine that manages the data, business logic and interactions with the hardware, in this case the Raspberry Pi. In our project, the backend is responsible for:

  • Save and organize photos and videos.
  • Manage configurations and notifications.
  • Ensure security with user authentication and authorization.
  • Communicate with the frontend via REST API.

What is Frontend (FE)?

The frontend is the part visible to users: the graphical interface with which they interact to use the system. It is designed to be simple and intuitive, showing photos and videos, and allowing access to all the functions offered by the backend. In our project, the frontend:

  • Displays thumbnails of photos and videos in an organized grid.
  • Allows browsing content with pagination.
  • Allows you to configure the system and manage users.
  • Provides a seamless experience on both desktop and mobile.

In short, the backend is the brain that processes and manages the data, while the frontend is the face of the system, the one that the user sees and uses. The combination of a powerful backend and an intuitive frontend makes our video surveillance system complete and easy to use.

FastAPI: the heart of the backend

What is FastAPI?

FastAPI is a modern and fast web framework for building APIs with Python. It is designed to be easy to use and highly performant, leveraging the power of Python’s asynchronous capabilities and Python’s static type system (type hints). Due to its flexibility and simplicity, it is ideal for projects that require well-structured REST APIs.

Where do we use it in the project?

FastAPI is the heart of our video surveillance system backend. It handles all the communication between the frontend (React) and the Raspberry Pi. The REST APIs we implemented allow you to upload and download photos and videos, generate thumbnails, send notifications, and even update system configuration files.

What functions does it perform?

In our project, FastAPI serves several key functions:

  1. REST API management: endpoints for uploading and retrieving photos and videos, configurations, notifications, and user management.
  2. ORM database: using SQLAlchemy, FastAPI stores photo and video metadata, ensuring fast and structured access to information.
  3. Real-time notifications: sends messages via Telegram, Pushover, and Gmail when new files are uploaded.
  4. System configurability: through dedicated endpoints, FastAPI allows you to update configuration parameters (such as photo/video mode or notification details).
  5. Security: authentication and authorization via JWT token, protecting access to sensitive data.

Thanks to FastAPI, our video surveillance system is robust, scalable and easy to maintain, ensuring that each component works in synergy with the others.

React: the intuitive frontend interface

What is React?

React is a JavaScript library developed by Facebook, used to create interactive user interfaces and modular components. Its philosophy is based on the concept of reusable components, which allow you to build scalable and dynamic interfaces.

Where do we use it in the project?

React is the heart of the frontend of our video surveillance system. It manages the display and user interaction with the system, allowing easy access to photos, videos and configurations through a user-friendly interface.

What functions does it perform?

In our project, React performs several key functions:

  1. Displaying photos and videos: with components like ThumbnailGrid and VideoGrid, React allows you to display thumbnails, play videos, and access stored files.
  2. Pagination: implements an advanced navigation system, which allows users to browse photos and videos in a fluid and organized way.
  3. Configurations and user management: through the AdminDashboard component, React offers tools for user administration and updating system configurations.
  4. Visual feedback: shows notifications or error messages directly to the user, improving the user experience.
  5. Responsiveness: thanks to React, the interface adapts perfectly to different devices, ensuring a fluid experience on both desktop and mobile devices.

React is not only a tool for creating beautiful pages, but it is also the engine behind the interactivity of the system. Every user action, such as navigating between pages or viewing photos and videos, is made immediate and effective thanks to its real-time rendering capabilities.

WireGuard: a modern, fast and secure VPN for video surveillance

WireGuard is a modern VPN (Virtual Private Network) protocol, designed to offer secure, fast and lightweight connections compared to more traditional solutions such as OpenVPN and IPsec. Created with a minimal architecture and based on next-generation cryptography, WireGuard is one of the most reliable solutions today for establishing secure remote connections.

Unlike many other VPNs, WireGuard was designed with a focus on simplicity and performance. Its code is extremely compact (less than 4,000 lines), which makes it easy to analyze for vulnerabilities and ensure a high level of security. In addition, the protocol uses modern and highly optimized cryptographic algorithms, including ChaCha20 for data encryption and Curve25519 for key authentication, ensuring greater security and speed than older VPN solutions.

Why is WireGuard the ideal choice for video surveillance on Raspberry Pi?

In a video surveillance context, the need for secure remote access is essential. WireGuard stands out for several key advantages:

  • Easy to configure: unlike IPsec or OpenVPN, which require complex and often difficult to debug configurations, WireGuard uses clean and easy-to-manage configuration files.
  • High performance: the protocol is optimized to run on low resources, making it ideal for devices with limited hardware like Raspberry Pi.
  • Advanced security: the use of modern cryptography ensures protection against MITM (man-in-the-middle) attacks and other vulnerabilities related to remote connections.
  • Stable and fast connection: thanks to efficient encryption key management and low latency, WireGuard offers smoother and more reliable connections than other VPNs.

How does WireGuard work?

WireGuard works on a peer-to-peer model, where each device connected to the VPN is identified by a pair of public and private keys. The server establishes a secure connection with authorized clients, allowing data traffic only to the IP addresses specified in the configuration.

In the case of our video surveillance application, WireGuard allows you to establish a private tunnel between the Raspberry Pi and remote devices (smartphone, tablet or PC), so you can access the web interface and video streams in complete security, even from public or untrusted networks.

A distinctive aspect of WireGuard is that it does not maintain persistent connections: packets are transmitted only when necessary, making the VPN much more efficient in terms of bandwidth and system resources.

Is WireGuard always the right solution?

While WireGuard is an excellent technology, there are some limitations to consider:

  • Technical requirements: to work in remote access, the server (the Raspberry Pi) must have a static public IP or the ability to use services such as Dynamic DNS.
  • Compatibility with internet providers: some ISPs may block incoming traffic or limit the use of VPNs, making further advanced configuration necessary.
  • Possible alternatives: in cases of particularly restrictive networks, solutions such as Tailscale (which is based on WireGuard) can offer an easier alternative to implement.

WireGuard is an excellent solution for those who want secure remote access to video surveillance without compromising performance. Thanks to its lightness, security and ease of configuration, it is perfect for those who want to control their system anytime and anywhere, while ensuring maximum protection of the home network.

What components do we need for the video surveillance project with Raspberry Pi and Docker?

The list of components is not particularly long:

  • a 5 Megapixel camera compatible with Raspberry Pi 3 Model B including flexible cable (flat)
  • a 32GB/64GB (micro) SD card formatted in FAT32
  • an optional USB WiFi dongle for the Raspberry
  • and, of course, a Raspberry!

I tested both 32GB and 64GB SD cards.

The project has been successfully tested on a Raspberry PI 3 Model B. The Raspberry PI 3 Model B is to be considered as a minimum requirement for the system to function correctly. At the moment it is not established whether the project can also be used directly on higher Raspberry models.

In the following photo you can see the camera on both the front and back side:

Front-back view of the camera
Front-back view of the camera

While in the next photo you can see a detail of the camera:

Detail of the camera used in the video surveillance system with Raspberry Pi
Detail of the camera used in the video surveillance system with Raspberry Pi

Preparing the Raspberry

In order to use the Raspberry, you need to do some preliminary steps and install some software.

Let’s start right away with the installation of the operating system.

The operating system chosen is a distribution made specifically to work on all types of Raspberry, even the oldest ones. The tests were done on a Raspberry PI 3 Model B.

If the Raspberry does not have a native wireless connection, you can use a WiFi dongle to insert into one of its USB sockets.

Let’s download and install the operating system on SD card

Download the latest version of the operating system at https://www.raspberrypi.com/software/operating-systems/

and go to the Raspberry Pi OS (Legacy) section. You will download a version that does not have a graphical environment so that it is as lightweight as possible:

The chosen distribution
The chosen distribution

The downloaded file will be compressed in xz format. To unzip it on Linux you will first need to install the tool:

sudo dnf install xz           su CentOS/RHEL/Fedora Linux.
sudo apt install xz-utils     su Ubuntu/Debian

and then give the command line:

xz -d -v filename.xz

where filename.xz is the name of the file you just downloaded containing the operating system.

On Windows, you can simply use one of these tools: 7-Zip, winRAR, WinZip.

The result will be a file with the img extension, which is the image to flash on the Raspberry SD card.

To flash the image to the SD card you will use the Balena Etcher tool which works on Linux, Windows and MACOS.

Its use is very simple: just select the image to flash, the destination SD card and press the Flash button.

Here is how its interface looks like:

The interface of the Balena Etcher tool
The interface of the Balena Etcher tool

On the left is set the image to flash, in the center the SD card to flash, on the right the button to start the flashing operation.

At the end of the operation the SD card will contain two partitions: boot and rootfs. In the device manager on Linux a menu like this appears:

Device Menu on Linux
Device Menu on Linux

Windows will also show such a menu: from your file explorer, under This computer you will see the 2 partitions.

Now, using a text editor, create a file on your computer that you will call wpa_supplicant.conf and edit it like this:

ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=«your_ISO-3166-1_two-letter_country_code»
network={
     ssid="«your_SSID»"
     psk="«your_PSK»"
     key_mgmt=WPA-PSK
}

You will need to replace the following entries:

  • «your_ISO-3166-1_two-letter_country_code» with your country identifier (for example for Italy it is IT)
  • «your_SSID» with the SSID name of your WiFi network
  • «your_PSK» with the WiFi network password

At this point, you will need to create an empty file that you will call ssh (without any extension).

The new distributions do not have the classic pi user with raspberry password, so to be able to enter via SSH, we have to provide another way.

With a working Raspberry we need to create a file called userconf that will contain the user we want to create with an encrypted version of the password we want to assign to it. The format will therefore be username:password-hash.

Suppose we want to keep the user pi, we need to create the password-hash. Suppose we want to create the hash of the password raspberry, always in the Raspberry where we created the userconf file. We need to give the following command, from the shell:

echo "raspberry" | openssl passwd -6 -stdin

This command will return the hash of the raspberry password. For example it could be a string like this:

$6$ROOQWZkD7gkLyZRg$GsKVikua2e1Eiz3UNlxy1jsUFec4j9wF.CQt12mta/6ODxYJEB6xuAZzVerM3FU2XQ27.1tp9qJsqqXtXalLY.

This is the raspberry password hash I calculated on my Raspberry.

Our userconf file will then contain the following string:

pi:$6$ROOQWZkD7gkLyZRg$GsKVikua2e1Eiz3UNlxy1jsUFec4j9wF.CQt12mta/6ODxYJEB6xuAZzVerM3FU2XQ27.1tp9qJsqqXtXalLY.

NOTE: it is necessary to calculate the hash with a Raspberry because the hash calculated with the computer uses another algorithm that would not allow the Raspberry we are preparing to recognize the password.

Alternatively you can download from the link below the userconf file that I created to have a pi user with a raspberry password.

Now open the boot partition on the SD card and copy the three files wpa_supplicant.conf, ssh and userconf into it. Safely remove the SD card from the computer and insert it into the Raspberry.

Turn on the Raspberry, wait a few minutes. In order to log in to the Raspberry in ssh, you will need to find out what its IP is (the one that the router assigned via DHCP).

To do this, simply give the command from a PC shell:

ping raspberrypi.local 

valid on both Linux and Windows (after installing Putty on Windows).

On my PC the Raspberry responds like this:

Raspberry Pi response to Ping
Raspberry Pi response to Ping

This tells me that the assigned IP is 192.168.43.27.

Alternatively you can use the Angry IP Scanner tool or you can access your router settings to see the devices connected via WiFi and find out what IP the Raspberry has.

To log in to the Raspberry via ssh, give the command from the shell (obviously in your case the IP will be different from this one):

with password raspberry. On Windows you need Putty.

Once inside the Raspberry give the following commands to update the software:

sudo apt update
sudo apt upgrade

The password is raspberry.

Let’s set up the timezone

To configure the timezone give the command:

sudo raspi-config

to the Raspberry shell. Suppose we want to set the time zone of Rome (here I will use the example of the time zone of Rome since I live in Italy, you will have to use the time zone of your country).

A screen like this will appear:

Initial screen of the sudo raspi-config command
Initial screen of the sudo raspi-config command

Select the location option and click OK:

Selected the localization option
Selected the localization option

Then select the timezone option and click OK:

Timezone option selected
Timezone option selected

Now select the geographic area and click OK:

Selected geographic area
Selected geographic area

Finally select the city and click OK:

Selected city
Selected city

That’s it!

Reboot the Raspberry by issuing the command:

sudo reboot

and after a few minutes, re-enter ssh as you did before.

Give the command

date

The Raspberry should now display the correct date and time.

Let’s set the static IP

In order for the Raspberry to always have the same IP address, we need to set it to be static. In my tests I set it to 192.168.1.190. If we didn’t do this, the router would assign it a different IP every time it was restarted, which would force us to change the IP address in our browser bar every time we access the application interface.

We will proceed in two steps:

  • we will set the fixed IP in the Raspberry
  • we will set the router to reserve that address for our Raspberry

For the first point, give the command:

nano /etc/dhcpcd.conf

to open the dhcpcd.conf file and edit it.

At the end of the file you will need to add a block like this:

interface [INTERFACE]
static_routers=[ROUTER IP]
static domain_name_servers=[DNS IP]
static ip_address=[STATIC IP ADDRESS YOU WANT]/24

where:

  • [INTERFACE] is the name of the WiFi interface (in our case it will be wlan0)
  • [ROUTER IP] is the address of our router (usually something like 192.168.0.1 or 192.168.1.1). You can find it by entering the administration interface of your modem/router
  • [DNS IP] is the address of the DNS server, which usually coincides with the [ROUTER IP] parameter of the modem/router
  • [STATIC IP ADDRESS YOU WANT] is the IP address that we want to assign as a fixed IP to the Raspberry

So, assuming that [ROUTER IP] = [DNS IP] = 192.168.1.1 and that [STATIC IP ADDRESS YOU WANT] = 192.168.1.190, the block will look something like this:

interface wlan0
static_routers=192.168.1.1
static domain_name_servers=192.168.1.1
static ip_address=192.168.1.190/24

Always reboot the Raspberry with the command

sudo reboot

and then log in again in ssh, this time with IP 192.168.1.190.

As a second step we will set the router so that it reserves the address 192.168.1.190 for our Raspberry. Each modem/router is different from the others but more or less they are similar. I will show here how mine looks.

To enter I type the address 192.168.1.1 (because my modem has this IP) on the browser and, after giving the administrator password, I arrive at the main screen. From here I have to look for the screen relating to access control.

Adding a static IP for the Raspberry
Adding a static IP for the Raspberry

There will be a button to add a static IP: add the chosen IP combined with the MAC address of the Raspberry WiFi card. I recommend that you consult the instruction manual of your modem/router for this operation.

Now verify that the Raspberry connects to the network by giving the command:

ping www.google.com

If you get a ping response, the network is connected. If you get a message like “Network is unreachable” give the command

sudo route add default gw [ROUTER IP]  

where [ROUTER IP] is the gateway which in our case is the router IP, i.e. 192.168.1.1

However, if when restarting the Raspberry again the network presents the problem of the type “Network is unreachable” proceed as follows:

create a script in /etc/dhcpcd.exit-hook to add the default gateway route with the following command:

sudo nano /etc/dhcpcd.exit-hook

and add this content:

#!/bin/sh
if [ "$interface" = "wlan0" ]; then
  /sbin/ip route add default via 192.168.1.1 dev wlan0
fi

Make the script executable with the command:

sudo chmod +x /etc/dhcpcd.exit-hook

Restart the network and the Raspberry:

sudo systemctl restart dhcpcd
sudo reboot

After the reboot, log in to the Raspberry again and try pinging the Google site again. If everything is resolved, the ping should be successful.

If you give the command

ip route

you should get the output:

default via 192.168.1.1 dev wlan0 
192.168.1.0/24 dev wlan0 proto dhcp scope link src 192.168.1.190 metric 303 

to confirm the success of the configuration.

Webcam installation and testing

Now we need to install the webcam. Open the Raspberry Pi configuration:

sudo raspi-config

and choose the option:

Camera configuration option
Camera configuration option

a second window will open where you will choose the Legacy Camera option:

Legacy Camera Choice
Legacy Camera Choice

Confirm your choice:

We confirm the Legacy Cam
We confirm the Legacy Cam

The configurator may warn us that this mode is deprecated and may not be available in the future. Let’s go ahead and reboot with the next screen:

Let's reboot the Raspberry
Let’s reboot the Raspberry

Once rebooted, we go back into ssh and, remaining in the home, we test it with the command:

raspistill -o test_photo.jpg

If the camera works, we should find a freshly taken photo on the home page, but if we get an error message, the camera may be faulty or badly connected. To check if the photo is present, we give the command:

ls -lah

among the listed files there should also be the file test_photo.jpg with its size.

To guarantee a trouble-free installation and prevent any future incompatibilities due to operating system updates, I provide you with an image already configured and tested. You can download it from the following link:


To flash it, for example with Balena Etcher, you will need a 64GB SD card.

The image includes a generic WiFi configuration file (/etc/wpa_supplicant/wpa_supplicant.conf) that looks like this:

ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=«YOUR_COUNTRY_ID»
network={
     ssid="YOUR_SSID"
     psk="YOUR_PASSWORD"
     key_mgmt=WPA-PSK
}

Before using the Raspberry Pi, you need to properly configure the WiFi connection with your wireless network data.

Connecting via Ethernet cable
Connect the Raspberry Pi to a network via Ethernet cable.

Finding the IP Address
To find the IP address of the Raspberry Pi:

Method 1: using ping raspberrypi.local (Recommended)

If your computer is on the same local network, you can use the name raspberrypi.local to find out the Raspberry Pi IP via mDNS (Multicast DNS).

Open Terminal (Linux/macOS) or Command Prompt (Windows) and type:

ping raspberrypi.local 

The command will return the IP address assigned to the Raspberry Pi, for example

PING raspberrypi.local (192.168.1.100): 56 data bytes

Use that address to connect via SSH:

The password is raspberry.

Note for Windows users: if raspberrypi.local doesn’t work, make sure you have Bonjour installed. Bonjour is included if you have iTunes installed.

Method 2: using the router interface

If ping raspberrypi.local doesn’t work, you can find out the Raspberry Pi’s IP address from your router’s web interface:

Log in to your router’s admin panel by typing its IP into your browser (usually 192.168. 1.1 or 192.168. 0.1).

Look in the Connected Devices or DHCP Clients section. You will find a list of connected devices and their IP addresses.

Identify the Raspberry Pi (it may be called raspberrypi).

Use that IP address to connect via SSH:

ssh pi@address

Method 3: connect a screen and keyboard to the Raspberry Pi, log in to the terminal (with user pi and password raspberry) and type:

hostname -I

to find out the IP assigned by the router.

Once you have discovered the IP address, access the Raspberry Pi via SSH:

(Replace 192.168.X.X with the correct IP address). The default password is raspberry.

Now edit the wpa_supplicant.conf file:

sudo nano /etc/wpa_supplicant/wpa_supplicant.conf

Replace the values <<YOUR_COUNTRY_ID>>, YOUR_SSID e YOUR_PASSWORD with those of your WiFi network.

Reboot the Raspberry Pi:

sudo reboot

NOTE: the parameter country=<<YOUR_COUNTRY_ID>> indicates your country code and must be replaced with the two-letter code according to the standard ISO 3166-1 alpha-2. For example Italy → IT, United States → US, France → FR. When you replace <<YOUR_COUNTRY_ID>>, remove the symbols << and >> too.

Assuming you are in Italy, this is how the wpa_supplicant.conf file should look like after the changes:

ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=IT

network={
     ssid="MyWiFiNetwork"
     psk="password1234"
     key_mgmt=WPA-PSK
}

If everything went well you can reboot the Raspberry with

sudo reboot

and unplug the LAN cable. Upon reboot you should be able to connect to the Raspberry via WiFi using its static IP and the ssh command:

with password raspberry.

Note to user

  • If you can’t connect:
    make sure you are on the same WiFi network and that the Raspberry Pi has actually connected.
  • If you have changed your WiFi network SSID or password, you will need to edit the wpa_supplicant.conf file again via an Ethernet connection.

⚠️ Note on Timezone ⚠️

The system image has a pre-configured time zone for Italy (Europe/Rome). If you are in another geographic area, it is recommended to change it to ensure that the logs and file times are correct. Follow the procedure in the “Let’s set up the timezone” paragraph to change it.

Installing the video surveillance application

The installation of the video surveillance system was designed to be simple and error-proof, thanks to the use of automatic scripts. I eliminated the need for complex interventions and manual configurations, leaving the scripts to prepare the environment, install the necessary dependencies, configure and start the services. Each step, from installing Docker to configuring file permissions, is performed automatically with a few commands.

The process obviously follows the preparation and configuration of the Raspberry as seen in the previous paragraph and is divided into three main phases:

  • the first phase involves downloading the application in compressed zip format, transferring it and unzipping it in the Raspberry home folder;
  • the second phase involves installing Docker and Docker Compose, also increasing the swap memory to ensure a more stable and faster compilation of the Python libraries;
  • the third phase configures the actual video surveillance system, creating the Docker containers, setting up the backend and frontend services and starting everything necessary to have the application working perfectly.

At the end of the installation the system is operational and ready to use, with all the services correctly configured. No advanced experience in Linux or server configuration is required, just a little patience to run the scripts and follow the simple instructions.

At this point download the project file from the following link:

and transfer it to the Raspberry home.

If you are on Linux, you will simply need to use rsync from the command line by opening a shell in the folder where the zip file is located and giving the command:

rsync -avzP videosurveillance.zip [email protected]:/home/pi/

If you are on Windows, I suggest two options:


1. WinSCP (Recommended method)

WinSCP is a graphical SCP/SFTP client that allows you to transfer files between Windows and Raspberry Pi with a user-friendly interface.

Instructions:

  1. Download and install WinSCP.
  2. Open WinSCP and choose SCP or SFTP protocol.
  3. Enter your Raspberry Pi details:
    • Host: 192.168.1.190
    • User: pi
    • Password: that of the pi user (if you haven’t changed it I remind you that it’s raspberry)
  4. Once connected, navigate to your local directory, select videosurveillance.zip and drag it to the /home/pi folder on the Raspberry Pi.

Pros: Simple interface, secure transfers.
Cons: Requires additional software installation.

2. FileZilla (Alternative method)

FileZilla supports SFTP, which is perfect for transferring files to your Raspberry Pi.

Instructions:

  1. Download and install FileZilla.
  2. Open FileZilla and go to File > Site Manager.
  3. Set up a new SFTP connection:
    • Host: 192.168.1.190
    • Protocol: SFTP – SSH File Transfer Protocol
    • User: pi
    • Password: that of the pi user (if you haven’t changed it I remind you that it’s raspberry)
  4. Click Connect and drag videosurveillance.zip to the /home/pi directory.

Pros: Easy to use, widely supported.
Cons: May seem oversized for a single file transfer.

Once the file has been transferred, log in to ssh with the command

where, if you have not changed it, the default password is raspberry and you will find it in the Raspberry home where you can unzip it with the command:

unzip videosurveillance.zip

The videosurveillance folder will be created. Enter it with the command

cd videosurveillance

You will find inside it several bash scripts and other types (for example python scripts).

First of all we have to install everything related to Docker by running the install_docker.sh script with the command

./install_docker.sh

If it is not already executable, make it so with the command:

chmod +x install_docker.sh

Once the installation is complete, reboot with the command

sudo reboot

Once the Raspberry is rebooted, go back into ssh and enter the videosurveillance project folder again.

The next step is to launch the setup_videosurveillance.sh installation script. If it is not already executable, make it so with the command:

chmod +x setup_videosurveillance.sh

and run it with the command:

./setup_videosurveillance.sh

During installation you may see a screen like this:

Kernel Version Notice
Kernel Version Notice

The system is just informing you that there is a newer kernel version available and suggests that you reboot your system once the installation is complete, to load the new kernel. Click OK and continue.

Wait for the installation to take place. On the Raspberry 3 it is a rather long procedure so do not worry.

At the end of the installation the system is already started (the necessary Linux services are created, enabled and started). To verify its behavior you can view the BE and FE logs by opening another shell and logging in with ssh in this one too.

To view the BE log give the command

docker logs videosurveillance_backend -f

in the first shell and the command

docker logs videosurveillance_frontend -f

in the second shell to view the FE log.

The pillars of the application: Motion and Docker

Introduction to Motion and setting up motion detection

Motion is a widely used open-source software for video camera motion detection, particularly appreciated in video surveillance projects due to its flexibility and ability to be customized according to the user’s needs. Motion is based on a configuration file called motion.conf, which allows you to control every aspect of motion detection, from sensitivity to frame rate, to the level of logging for event monitoring.

This configuration file is the heart of Motion, as it contains the parameters that determine not only the quality of detection but also the management of false positives and the organization of captured images. Each parameter in motion.conf plays a fundamental role in balancing the sensitivity of the software, avoiding unnecessary notifications for minimal events, while ensuring that any significant activity is detected and documented.

Since Motion is designed for use in very different environments (indoors, outdoors, with variable lighting, etc.), it offers a wide range of options. In contexts such as a porch, where light conditions can vary throughout the day and small movements (for example leaves blowing in the wind) can be mistaken for events, it is essential to find the right calibration to avoid overloading of photos and notifications. Configuring the parameters in motion.conf allows you to define specific sensitivity thresholds and reaction modes, such as the number of consecutive frames needed to confirm a movement or the filter to apply to reduce background noise.

Below, we will explore the main parameters of motion.conf with the aim of optimizing detection for dynamic environments, providing tips on how to avoid false positives and obtain reliable results even in variable lighting conditions. These parameters can be adapted to obtain a configuration that meets the specific needs of each video surveillance project, be it professional or domestic.

An example of the motion.conf file used in this project is the following:

daemon on
log_level 6
output_pictures on
ffmpeg_output_movies off
target_dir /app/photos/motion
threshold 4000             
minimum_motion_frames 10   
event_gap 15                
noise_level 64              
noise_tune on               
despeckle_filter EedDl      
auto_brightness on          

Basic parameters for motion detection

  • daemon on: runs Motion in the background as a daemon. Important for starting Motion as a standalone service.
  • log_level 6: sets the logging level; a higher value provides more details about what happens during execution, useful for debugging.
  • output_pictures on: enables saving images on motion detection. This parameter must be set to “on” to get snapshots whenever Motion detects a significant change.
  • ffmpeg_output_movies off: disables video output creation, useful for saving disk space when you only want images.
  • in case you want to capture videos, the settings will be output_pictures off and ffmpeg_output_movies on

Sensitivity and false positives

  • threshold 2000: this value represents the sensitivity to motion. A lower value increases the sensitivity, while a higher value reduces it. With a high threshold, Motion will ignore small changes in the scene, reducing false positives. Recommended: between 2000 and 4000.
  • minimum_motion_frames 3: defines the minimum number of consecutive frames that must show motion for it to be considered real. Setting this parameter higher can reduce false positives, as it requires longer duration motion.
  • event_gap 10: sets the time interval (in seconds) between successive motion events. A higher value reduces duplicate photos for a single event, reducing burst output.
  • noise_level 128: is used to reduce background noise in images. The higher the value, the greater the tolerance to video noise.
  • noise_tune on: automatically adjusts Motion sensitivity to the ambient noise level, useful in scenarios where lighting conditions may change, such as on a veranda.
  • despeckle_filter EedDl: this filter removes small changes, such as noise, further reducing false positives. The letters indicate the types of filters that apply, and typically an “EedDl” pattern is effective for noisy environments.
  • auto_brightness on: allows Motion to adapt to changes in ambient light, useful in outdoor environments or areas with variable lighting.

Output management and image rotation

  • target_dir /app/photos/motion: specifies the target directory for photos. It is recommended to maintain an organized folder structure to avoid accumulation of unmanaged files. If the system is set to capture video, the target_dir parameter would be set to /app/videos/motion
  • max_mpeg_time 0: set to zero to avoid time limits on videos.

The motion.conf file is located in the videosurveillance system folder but it is not necessary to edit it manually (indeed it is highly discouraged). Its configuration is possible (as we will see later) via the configuration page of the web interface shown by the FE.

Docker: a platform to simplify development and deployment

Docker is an open-source platform designed to automate application deployment through lightweight containers. A container is a software unit that encapsulates an application and its dependencies, ensuring that it can run anywhere, regardless of environment. This makes Docker an extremely powerful tool for developing and deploying complex applications, like our Raspberry Pi-based video surveillance system.

Advantages of Docker in our project

  1. Dependency isolation: Docker allows you to package all the dependencies (such as Python, specific libraries, etc.) needed for the video surveillance system to function. This avoids compatibility issues that can occur on different operating systems.
  2. Ease of deployment: creating a Docker container allows us to run the project on any device that supports Docker, be it a Raspberry Pi, a PC, or a remote server. No matter where it is run, the application will work the same way.
  3. Reproducibility: with Docker, we can ensure that the system runs exactly as expected every time it is started. This eliminates errors caused by differences in the development environment.
  4. Ease of updating: when we update or improve the project, we can create a new version of the container without having to manually update each component of the system. Containers can be updated easily by deploying a new image.

How we use Docker in our project

In our video surveillance project, Docker runs the application that captures the photos or videos, generates thumbnails, sends notifications via Pushover/Telegram/Gmail, and provides an API for interacting with the system. Here are the main steps we followed to use Docker:

  1. Dockerfile: the Dockerfile defines the environment in which the application will run. It includes the base of the operating system (for example, a lightweight version of Python), the required libraries (such as Pillow for image processing), and the source code of the project. This is the heart of the container.
  2. Image creation: with Docker, we build an image based on the Dockerfile, which includes everything needed to run the application. The image is a sort of “snapshot” of the system that runs our software.
  3. Container execution: once the image is created, we can start the container that will run the application. Every time the system starts, it runs the application exactly as configured in the Dockerfile.
  4. Docker Compose: Docker Compose allows us to easily manage multiple containers and configurations. In this project, for example, it could be used to simultaneously run the video surveillance application and other related services, such as a separate database or other system components.

Why use Docker in our project?

  • Automation: Docker makes project deployment and automation much easier. Instead of manually configuring each component of the system on each new Raspberry Pi device, we can simply deploy the pre-configured Docker container.
  • Time savings: with Docker, we don’t need to manually install all the dependencies on each machine, which reduces the time it takes to get the system up and running.
  • Modularity: we can manage different parts of the project (e.g. image capture, thumbnail management, and notifications) in separate containers and have them communicate with each other via the internal network, providing greater flexibility and ease of management.

Docker is therefore an essential tool to ensure the scalability, reproducibility and ease of management of our video surveillance project on Raspberry Pi. Whether it is a single device or multiple systems, Docker offers us a robust and modular platform to deploy and manage the software without complications.

Docker vs. Virtual Machine: key differences

Docker is often confused with a virtual machine (VM) like those created with software like VMware or VirtualBox, but there are fundamental differences between the two approaches. While both provide application and operating system isolation, they do so in completely different ways, with significant implications for performance, management, and efficiency.

1. Architecture
  • Virtual Machine (VM): a VM is an entire virtualized operating system that runs on a hypervisor (such as VMware or VirtualBox), which, in turn, runs on top of the host operating system. Each VM contains a complete installation of the operating system (Windows, Linux, etc.), its libraries and dependencies, and the application itself. This means that a VM requires an entire virtual “copy” of the operating system, including the kernel, drivers, and all system resources.
  • Simplified diagram of a VM:
    • Hardware
    • Host OS (e.g. Windows, Linux)
    • Hypervisor (e.g. VMware, VirtualBox)
    • Guest OS (e.g. Linux, Windows)
    • Application and Dependencies
  • Docker: Docker, on the other hand, uses containers, which share the same kernel as the host operating system. Instead of virtualizing an entire operating system, Docker virtualizes only the application environment, isolating dependencies and the application itself. This makes Docker much lighter than a VM because there is no need for a separate operating system for each container.
  • Simplified Docker Schematic:
    • Hardware
    • Host OS (e.g. Linux, Windows)
    • Docker engine
    • Container (application + dependencies)
2. Performance and resources
  • Virtual Machines: because a VM has to run an entire operating system, it consumes much more resources. It requires CPU, RAM, and disk space to support both the hypervisor and the virtualized operating system. Each VM can be several gigabytes in size, and performance can be slowed down due to hypervisor overhead and duplicated resources.
  • Docker: containers are extremely lightweight. Because they share the host operating system kernel and do not have to run an entire guest operating system, overhead is minimal. This means you can run many more containers on a single machine than VMs, with much more efficient resource utilization.
3. Startup and management
  • Virtual Machines: booting a VM can take minutes, as it needs to boot the entire guest operating system and its services. Each time you boot a VM, it is like booting a full computer. VM management can be complex, requiring OS updates and manually configuring drivers, networks, etc.
  • Docker: Docker containers are designed to be fast. Because they rely on the host system kernel and do not need to boot an entire operating system, booting a container takes only a few seconds. Docker also makes container management easy through tools like Docker Compose, which allows you to manage multiple containers at once with ease.
4. Portability
  • Virtual Machines: a VM can be moved and run on another machine, but due to its size and the complexity of the virtualized operating system, this is not always quick or easy. Each VM includes an entire operating system installation, making it much heavier and less portable.
  • Docker: Docker is designed for portability. Because containers are lightweight and include only the essential dependencies for the application, they can be easily moved and run on any machine that supports Docker, regardless of the host operating system. This portability is one of the main advantages of Docker, allowing you to run applications without worrying about differences between environments.
5. Safety
  • Virtual Machines: VMs offer a high degree of isolation because each VM has its own separate operating system. If a VM is compromised, the hypervisor and other VMs are generally safe because they do not share direct resources.
  • Docker: Docker also offers isolation, but because containers share the same kernel as the host operating system, there is a greater security risk than VMs, especially if the container is running with elevated privileges. That said, Docker has taken many security measures, such as the use of namespaces and cgroups, to mitigate these risks.
6. Use in video surveillance project

In the context of our video surveillance project, Docker offers a significant advantage over VMs. Instead of setting up a full virtual machine with a separate operating system, Docker allows us to run a lightweight container that contains only the application and its dependencies, ensuring optimal performance even on limited hardware such as the Raspberry Pi. Docker’s portability means that we can develop the system on one machine and easily deploy it to others without having to reconfigure the environment each time. This makes Docker perfect for a project that requires flexibility, portability, and ease of management.

Summary of differences
FeatureDockerVirtual Machine
IsolationProcess and kernel level isolationComplete isolation at the operating system level
Resource usageVery lightweight (shares host system kernel)Heavy (each VM runs a full operating system)
StartupStarts up in secondsStartup in minutes (depends on OS)
PortabilityHighly portableLimited and slower portability
SafetyGood isolation, but with risks if poorly configuredHigh isolation thanks to the hypervisor

Enabling and configuring real-time notifications and communications

Pushover: real-time notifications for your video surveillance project

Pushover is a push notification platform designed to send instant messages to mobile, tablet and desktop devices. It is particularly useful in scenarios like our video surveillance project, where it is essential to receive real-time alerts, for example when motion is detected or a new image is uploaded.

How does Pushover work?

  1. Creating an account: to get started, you need to register on Pushover.net. After creating your account, you can download the Pushover app available for Android, iOS, and as a desktop client.
  2. Registering an application: once registered, you can create a new “application” directly from your account. This application will represent your video surveillance project. Each application has an API Token, a unique code that you will use to authenticate and send notifications to your device.
  3. Token configuration: after creating your application, you will be assigned an API Token. This will be necessary to integrate Pushover into your Python code. Every time you send a notification, your script will include this token, along with the recipient’s User Key (can be found in the user section of your Pushover account).
  4. Cost and limitations:
    • Pushover offers a 30-day free trial during which you can send up to 7,500 notifications per month.
    • After the trial period (at the time of writing) a one-time payment of $5 per platform (Android or iOS) is required. There are no recurring subscriptions, and this license allows for unlimited notifications (up to 7500 per month).
    • You can receive notifications on multiple devices at the same time, connected to the same account.
  5. Sending notifications:
    • Pushover supports various types of notifications, including simple text messages, image notifications (such as thumbnails of photos taken by the camera), and hyperlinks.
    • In your project, you can include the photo thumbnail as an attachment to the notification, allowing the user to directly see the image in real time on their phone.
    • The notification can also contain a custom title, a detailed message, and the link to the uploaded photo.
  6. Safety: Pushover notifications are end-to-end encrypted, ensuring your messages and images are transmitted securely. Additionally, you can customize the priorities of your notifications, deciding whether they should appear as critical alerts or regular messages.

Integration into the project

In our project, Pushover can be used to notify the user whenever a new photo is uploaded or when motion is detected. Once the token and API are configured, you can send a notification directly from Python code as follows:

import requests

def send_notification(api_token, user_key, message, image_path=None):
    data = {
        "token": api_token,
        "user": user_key,
        "message": message,
    }
    files = {"attachment": open(image_path, "rb")} if image_path else None
    response = requests.post("https://api.pushover.net/1/messages.json", data=data, files=files)
    return response

The message and image (e.g. the created thumbnail) will be sent to the device registered on Pushover.

Pros and Cons of Pushover

Pros:

  • Simple and straightforward: Pushover is extremely easy to set up and use, making it ideal for DIY projects like ours.
  • Low cost: the one-time $5 per platform license is an affordable option compared to other recurring payment notification services.
  • Compatibility: it works on Android, iOS, and desktop, allowing you to receive notifications on any device.

Cons:

  • Free limit: the 30-day trial is free, but for continued use after the monthly limit or at the end of the trial period, you need to pay.
  • Account requirement: the end user still needs to create an account on Pushover and download the application to receive notifications.

Let’s look at the registration process in more detail.

Pushover Registration Process

Account creation:

  • Go to Pushover.net and click “Sign Up” to register.
  • Enter a valid email and choose a secure password.
  • After filling out this information, you will receive a confirmation email. Click on the link in the email to verify your address.

Email verification:

  • Once you confirm your email, you will be redirected to the main page of your account, as shown in the following image.
  • In this screen, you can see your User Key, the unique code associated with your account. This User Key is important because you will use it to receive notifications on your device. You can also provide it to applications that need to send you notifications via Pushover.
Home page of the account created on Pushover
Home page of the account created on Pushover

Account management page: in the previous image you can see several important sections that deserve a detailed explanation:

  • Your User Key: this is the unique code that identifies your account on Pushover. You will use it in your project code to send notifications to the device linked to your account. This code must be protected, as it is the identifier for receiving notifications on your device.
  • Devices: this section lists all the devices on which you have installed the Pushover app. In this case, you see that the Android device is connected. You can add other devices, such as tablets or desktops, by clicking Add Phone, Tablet, or Desktop. If multiple devices are registered, you can choose which specific device to send notifications to, or send them to all of them at once.
  • E-Mail Aliases: this feature allows you to receive notifications directly from emails sent to a Pushover alias, such as [email protected]. Emails sent to this address will be transformed into push notifications and delivered to the devices linked to your account. This is a useful feature for turning emails into quick and personalized notifications.
  • Applications: here you can create new applications. Each application will have an API Token, which is needed to send notifications from your project or service. The application has a limit of 10,000 messages per month (free notifications).
  • Usage statistics: you can monitor the number of notifications sent through the application, divided between Free Messages and Paid Messages.

Creating a new Token Application/API on Pushover

After completing the registration and installation of the Pushover app, the next step is to create a new application. This will allow you to receive automatic push notifications from your project or website directly to your device.

Pushover application creation page
Pushover application creation page

In the image above, the form for creating a new application/API Token on Pushover is shown. Below, we explain the various fields and steps to successfully create your application.

Why do you need to create an application?

  • Purpose: the application you create will generate a unique API Token. This token is essential to allow Pushover to send push notifications to registered devices. Each application can send up to 10,000 free notifications per month.
  • Integration with projects: creating an API Token is a mandatory step to integrate your push notification system, whether it is for a video surveillance project, a monitoring service, or any other automation.

Form fields

  1. Name:
    • Enter a name for your application. The name should be short and descriptive (for example: “Video Surveillance”, “Monitoring Notifications”).
    • This name will be displayed as the default title in notifications sent, unless otherwise specified.
  2. Description (optional):
    • You can enter a description of your application. This field is optional, but can help you remember the context or purpose of the application, especially if you create more than one.
  3. URL (optional):
    • If your application is associated with a website or GitHub repository, you can enter the URL here. This is especially useful for public apps or plugins.
  4. Icon:
    • You can upload a custom icon for your app, which will be displayed alongside notifications. The icon must be in PNG format with dimensions of 72×72 pixels and preferably with a transparent background. If you upload an image of a different size, it will be automatically resized.
    • It is an aesthetic detail that can improve the presentation of notifications, making them visually recognizable.
  5. Acceptance of Terms of Service:
    • Before creating the application, you must accept Pushover’s Terms of Service. Make sure to check the box.

Final steps

  • After filling out the required fields, click on Create Application. Once done, your API Token will be generated, which will be visible on the application management page.
  • This API Token is what you will use in your code to integrate Pushover and send automated push notifications.

Creating an application on Pushover is a crucial step to send automated push notifications. Using the generated Token API, you can configure your Python (or other languages) scripts to send real-time notifications directly to the devices connected to your Pushover account.

In short, Pushover is a practical and versatile tool that can easily be integrated into automation and surveillance projects like ours, ensuring fast and effective notifications on mobile and desktop devices.

Setting up Gmail for email notifications

In order to activate notifications via Gmail, you obviously need to have a Gmail email.

To configure sending notifications via email via Gmail, you need to activate two-factor authentication and create an “app password”. This special password will be used by the system to access your Gmail account without compromising security and without using your Gmail password.

Steps to setup Gmail account

  1. Enable Two-Factor Authentication for Gmail Account
    • Log in to your Gmail account: https://mail.google.com.
    • Visit the security management section of your account: https://myaccount.google.com/security.
    • Find the “How do you sign in to Google” section and select Two-Step Verification.
    • Follow the instructions to enable two-factor verification, choosing an authentication method (e.g. SMS, authenticator app).
    • Once enabled, you will be taken to the security page with two-step verification enabled.

Create an App Password

  • From the same security page, look for the “App Passwords” section (visit https://myaccount.google.com/apppasswords).
  • Enter a descriptive name for this password, such as “Video Surveillance” or “RaspberryPi”, so you can easily recognize it.
  • Click Create. Google will provide you with an app password consisting of 16 characters, separated into blocks of four letters (e.g. abcd efgh ijkl mnop).
  • Please write down this password and make sure you copy it correctly (with or without spaces, the full version will be accepted in the configuration system).

In the config.ini file, in the [notification] section you will find a part like this:

gmail_enabled = true
smtp_server = smtp.gmail.com
smtp_port = 587
sender_email = [email protected]
app_password = gkmyabypokmn
receiver_email = [email protected]

You could change it with your data (leaving the smtp_server and smtp_port fields unchanged) but it is highly discouraged to manually change the application configuration files. For this kind of operations you can use the appropriate section in the configuration web page, so from now on use only that.

How to create a Telegram bot

Telegram is an instant messaging and VoIP application that can be installed on your smartphone (Android and iPhone) or computer (PC, Mac and Linux). Telegram allows you to create bots that our device can interact with.

Let’s create our bot now!

If you don’t already have Telegram, install it and then search for the botFather bot. Click on the item displayed. The following screen will appear:

First screen of botFather bot
First screen of botFather bot

Type the command /start to read the instructions:

Instructions for creating the bot
Instructions for creating the bot

Now type the command /newbot to create your bot. Give it a name and a username:

Creating the new bot
Creating the new bot

If your bot was created successfully, you will receive a message with a link to access the bot and the bot token.
Save the bot token because you will need it later for the board to interact with the bot.

Here is what the screen where the bot token is written looks like:

The bot token
The bot token

At this point you need to find your Telegram user ID. But…..how do we find this ID?

In your Telegram account, search for IDBot and start a conversation with that bot:

The first screen of IDBot
The first screen of IDBot

Then type the command /getid and it will reply with your ID:

The result of the /getid command
The result of the /getid command

At this point we have created our bot and we have all the elements to interface it with our device: the username, the token and the userid. We will use this data to insert them into the configuration file config.ini.

The bash management scripts

To simplify the management of the video surveillance system, several Bash scripts have been developed that allow you to perform maintenance and administration operations with a single command. These scripts eliminate the need for complex manual interventions, ensuring greater efficiency, security and ease of use for the user.

With these tools, you can quickly perform operations such as:

  • Docker container cleanup and management
  • Backup restore
  • Database management
  • Raspberry Pi safe shutdown
  • Automated system configuration
  • WireGuard installation for external network access

Below is a detailed overview of each script and what it does.

The scripts are:

  • clean_data.sh
  • hard_clean_docker.sh
  • poweroff_raspberry.sh
  • reset_admin_db.sh
  • restore_backup.sh
  • setup_videosurveillance.sh
  • install_docker.sh
  • soft_clean_docker.sh
  • install_wireguard.sh

The clean_data.sh script

The clean_data.sh script is designed to delete all data from the video surveillance system, restoring the environment to a “clean” state. This means that all photos, videos and the database will be permanently removed. Here’s how it works:

#!/bin/bash

# Stop the service
echo "Service shutdown videosurveillance.service..."
sudo systemctl stop videosurveillance.service || {
    echo "Error stopping service."
    exit 1
}

# Delete the photos, videos and database folders on the container and host
echo "Clean up photos, videos and database folders..."

# Removes photos, thumbnails and videos
rm -rf ./photos/*
rm -rf ./videos/*

# Removes the database
rm -f ./data/videosurveillance_db.sqlite

# Restores an empty version of the database
cp -f /home/pi/videosurveillance/empty_videosurveillance_db.sqlite /home/pi/videosurveillance/data/videosurveillance_db.sqlite

sudo chown -R root:root data
# Restarting the service
echo "Restarting the service videosurveillance.service..."
sudo systemctl start videosurveillance.service || {
    echo "Error starting service."
    exit 1
}

echo "Cleaning completed."

The line

#!/bin/bash

specifies that the script should be run using Bash.

The block

# Stop the service
echo "Service shutdown videosurveillance.service..."
sudo systemctl stop videosurveillance.service || {
    echo "Error stopping service."
    exit 1
}

stops the videosurveillance.service service.

The following line prints a message indicating that the cleaning operation is in progress:

# Delete the photos, videos and database folders on the container and host
echo "Clean up photos, videos and database folders..."

The next lines:

# Removes photos, thumbnails and videos
rm -rf ./photos/*
rm -rf ./videos/*

delete all files inside the photos/ folder, including image thumbnails, and all files in the videos/ folder, including videos and their thumbnails.

The line

# Removes the database
rm -f ./data/videosurveillance_db.sqlite

deletes the SQLite database file that contains the metadata for images and videos.

The block

# Restores an empty version of the database
cp -f /home/pi/videosurveillance/empty_videosurveillance_db.sqlite /home/pi/videosurveillance/data/videosurveillance_db.sqlite

sudo chown -R root:root data

restores an empty version of the database but with the structure already created and assigns it the correct owner and group.

The block

# Restarting the service
echo "Restarting the service videosurveillance.service..."
sudo systemctl start videosurveillance.service || {
    echo "Error starting service."
    exit 1
}

restarts the videosurveillance.service service.

While the line

echo "Cleaning completed."

prints a message to confirm that the cleaning operation is complete.

⚠️ WARNING!

  • This script does not ask for confirmation before deleting data. Once executed, images, videos and database will be permanently deleted;
  • It is recommended to make a backup before running this script if you want to keep your data.

Usage

To run the script, give the command:

./clean_data.sh

If it is not executable, make it so with:

chmod +x clean_data.sh

This script is especially useful when you want to start from scratch without having to manually delete files.

The hard_clean_docker.sh script

The hard_clean_docker.sh script performs a complete and destructive cleanup of the Docker environment and system files of the video surveillance project.

All containers, images, networks, Docker volumes, and system services related to video surveillance will be deleted. Additionally, all files and databases, including backups and logs, will be erased.

⚠️ WARNING! This script restores the environment to a completely clean state, as if the system had never been installed. Deleted data cannot be recovered!

#!/bin/bash

echo "Running Docker clean hard... Warning: it will delete everything!"

# Confirmation by the user
read -p "Do you want to continue? This action will delete all containers, images, networks, Docker volumes and Linux services! [y/N]: " response
if [[ "$response" != "y" && "$response" != "Y" ]]; then
    echo "Operation cancelled by user."
    exit 0
fi

# Stop Linux services
for service in videosurveillance.service host_service.service; do
    echo "### Stopping the service $service ###"
    if systemctl is-active --quiet "$service"; then
        sudo systemctl stop "$service"
        echo "Service $service stopped."
    else
        echo "Service $service was not running."
    fi
done

# Initial state
echo "### Initial state of Docker resources ###"
docker system df

# Stop all containers
echo "### Stopping all containers... ###"
docker stop $(docker ps -aq) || echo "No containers running."

# Delete all containers
echo "### Removing all containers... ###"
docker rm $(docker ps -aq) || echo "No containers to remove."

# Delete all images
echo "### Removing all images... ###"
docker rmi $(docker images -q) || echo "No images to remove."

# Delete all non-default networks
echo "### Removing all non-default Docker networks... ###"
docker network prune -f || echo "No custom networks to remove."

# Delete all volumes
echo "### Removing all Docker volumes... ###"
docker volume prune -f || echo "No volumes to remove."

# Final state
echo "### Final state of Docker resources ###"
docker system df


# Remove Linux Services
for service in videosurveillance.service host_service.service; do
    echo "### Removing the service $service ###"
    if [ -f "/etc/systemd/system/$service" ]; then
        sudo systemctl disable "$service"
        sudo rm "/etc/systemd/system/$service"
        echo "Service $service successfully removed."
    else
        echo "The $service service did not exist."
    fi
done

# Reload systemd to remove any residual references
sudo systemctl daemon-reload

echo "Clean up photos, videos and database folders..."

# Removes photos, thumbnails and videos
sudo rm -rf ./photos/*
sudo rm -rf ./videos/*

# Removes the database
sudo rm -f ./data/videosurveillance_db.sqlite

# Remove the backup
echo "Cleaning backups..."
sudo rm ./backup.zip
sudo rm ./db_dump.db

# Removes the log
echo "Cleaning the log..."
sudo rm ./restore_backup.log


echo "File cleanup completed."


echo "Hard cleanup completed. All Docker and system data have been reset."

The lines

#!/bin/bash
echo "Running Docker clean hard... Warning: it will delete everything!"

specify the use of the Bash shell to execute the script and display a warning message to the user.

The lines

read -p "Do you want to continue? This action will delete all containers, images, networks, Docker volumes and Linux services! [y/N]: " response
if [[ "$response" != "y" && "$response" != "Y" ]]; then
    echo "Operation cancelled by user."
    exit 0
fi

ask the user to confirm by typing y (or Y) to proceed. If the user presses any other key, the operation is canceled and the script stops.

The lines

for service in videosurveillance.service host_service.service; do
    echo "### Stopping the service $service ###"
    if systemctl is-active --quiet "$service"; then
        sudo systemctl stop "$service"
        echo "Service $service stopped."
    else
        echo "Service $service was not running."
    fi
done

stop the videosurveillance.service and host_service.service services if they are running. If a service is not running, the script reports it.

The lines

echo "### Initial state of Docker resources ###"
docker system df

show the current usage of Docker resources (images, volumes, containers, networks) before cleanup.

The lines

echo "### Stopping all containers... ###"
docker stop $(docker ps -aq) || echo "No containers running."

echo "### Removing all containers... ###"
docker rm $(docker ps -aq) || echo "No containers to remove."

echo "### Removing all images... ###"
docker rmi $(docker images -q) || echo "No images to remove."

echo "### Removing all non-default Docker networks... ###"
docker network prune -f || echo "No custom networks to remove."

echo "### Removing all Docker volumes... ###"
docker volume prune -f || echo "No volumes to remove."
  • stop and remove all Docker containers
  • delete all Docker images
  • delete all custom Docker networks
  • stop and remove all Docker containers
  • delete all Docker images
  • delete all custom Docker networks
  • remove all Docker volumes

If one of the resources does not exist, a message is displayed without generating errors.

After cleaning, the script displays the updated status of the Docker resources:

echo "### Final state of Docker resources ###"
docker system df

The lines

for service in videosurveillance.service host_service.service; do
    echo "### Removing the service $service ###"
    if [ -f "/etc/systemd/system/$service" ]; then
        sudo systemctl disable "$service"
        sudo rm "/etc/systemd/system/$service"
        echo "Service $service successfully removed."
    else
        echo "The $service service did not exist."
    fi
done

# Reload systemd to remove any residual references
sudo systemctl daemon-reload

disable and delete the videosurveillance.service and host_service.service services from the system and reload systemd to remove residual references to the services.

The lines

echo "Clean up photos, videos and database folders..."
sudo rm -rf ./photos/*
sudo rm -rf ./videos/*
sudo rm -f ./data/videosurveillance_db.sqlite
# Remove the backup
echo "Cleaning backups..."
sudo rm ./backup.zip
sudo rm ./db_dump.db

# Removes the log
echo "Cleaning the log..."
sudo rm ./restore_backup.log

completely delete all files in the photos/ and videos/ folders, delete the videosurveillance_db.sqlite database, removing all metadata of photos and videos. Delete the backup file backup.zip and the database dump db_dump.db. Delete the restore_backup.log log to avoid residues of previous sessions.

The final message

echo "File cleanup completed."
echo "Hard cleanup completed. All Docker and system data have been reset."

confirms that all files, containers, images and services have been removed.

⚠️ Be careful before running this script!

  1. All data will be lost. The script does NOT create a backup before deletion.
  2. Docker will be completely emptied, so you will need to rebuild everything with setup_videosurveillance.sh.
  3. Make sure you really want to perform a full clean before confirming with y.

Usage

Make the script executable (if it isn’t already) by issuing the command:

chmod +x hard_clean_docker.sh

Run the script with the command:

./hard_clean_docker.sh
📌 When to use hard_clean_docker.sh?

✅ When you want to completely delete the system and start from scratch.
✅ If Docker or services are corrupt and need to be reinstalled and configured from scratch.
✅ After testing the system and want to do a thorough clean before a fresh install.

📌 When NOT to use hard_clean_docker.sh?

❌ If you want to keep data and configurations (in this case use soft_clean_docker.sh).
❌ If you just want to delete photos/videos, there is a clean_data.sh script for this purpose.
❌ If you are not sure you want to completely reset Docker and your system.

🔄 Alternative: Soft Cleanup

If you don’t want to delete everything, you can use soft_clean_docker.sh, which does a lighter cleanup without removing images and services.

The poweroff_raspberry.sh script

The poweroff_raspberry.sh script is a simple but important shell script that allows you to safely and orderly shutdown the Raspberry Pi, ensuring that all active services related to the video surveillance system are properly closed before the shutdown. This prevents data corruption and problems related to an unexpected shutdown, protecting the system and the database.

#!/bin/bash

echo "Stopping videosurveillance service..."
sudo systemctl stop videosurveillance.service

echo "Stopping host_service service..."
sudo systemctl stop host_service.service

echo "Shutting down Raspberry PI..."
sudo shutdown -h now

The lines

#!/bin/bash

echo "Stopping videosurveillance service..."
sudo systemctl stop videosurveillance.service

echo "Stopping host_service service..."
sudo systemctl stop host_service.service

specify that the script should be run using the Bash shell, stop the videosurveillance.service service, which handles image and video acquisition and monitoring. This ensures that all ongoing operations are properly completed before shutdown. Stop the Flask service, which handles some system APIs, such as remote service restart. This ensures that the backend does not receive pending requests during the shutdown process.

The lines

echo "Shutting down Raspberry PI..."
sudo shutdown -h now

warn the user that the Raspberry is about to shut down, by running the shutdown -h now command, which:

  • stops all running processes
  • shuts down the operating system in an orderly manner
  • disables all active connections and protects the filesystem

The -h (halt) parameter indicates that the system should stop completely.

💡 When to use poweroff_raspberry.sh

✅ When you want to safely shutdown your Raspberry Pi without risking data corruption.
✅ Before disconnecting physical power to avoid damage to the filesystem or database.
✅ When the system is in normal operating state but an orderly shutdown is required.

⚠️ WARNING!

  • the script requires administrator privileges (sudo), so it must be run as a user with these permissions
  • do not forcefully shut down the Raspberry Pi by unplugging the power without using this script

Usage

If the script is not already executable, make it so by issuing the command:

chmod +x poweroff_raspberry.sh

Run the script by giving the command:

./poweroff_raspberry.sh
🚨 What the script does in a nutshell:
  1. Stops the main video surveillance service.
  2. Stops the Flask service that handles the system APIs.
  3. Safely shuts down the Raspberry Pi, preventing database and critical files from being corrupted.
🔄 Differences with a forced shutdown
MethodData securityShutdown of servicesFilesystem protection
poweroff_raspberry.sh✅ Safe✅ Completed✅ Protected
Disconnect the power❌ Not safe❌ Not completed❌ Possible corruption

This script is essential to keep your video surveillance system stable and reliable.

The reset_admin_db.sh script

This script is used to reset the user table and reset the credentials of the administrator user of the video surveillance system saved in the SQLite database used by the video surveillance system. It is particularly useful in situations where access to the administrative account is lost, returning the state of the user table to the initial one (and therefore deleting all users except the administrator who has id = 1).

#!/bin/bash

# Path to SQLite file
DB_PATH="/home/pi/videosurveillance/data/videosurveillance_db.sqlite"

# Check if the file exists
if [ ! -f "$DB_PATH" ]; then
    echo "Error: Database does not exist at the specified path: $DB_PATH"
    exit 1
fi

# SQL commands to execute
SQL_COMMANDS="
DELETE FROM users WHERE id != 1;
UPDATE users
SET username = 'admin',
    password = '$pbkdf2-sha256\$29000$RwiBcO4dY4wRAoAQwjjnnA\$9iWG5vcAalsIN8tfWNHZj/nJausfezdDa16YzeabKcs',
    is_admin = 1
WHERE id = 1;
"

# Execute SQL commands on the database
echo "Making changes to the database..."
sudo sqlite3 "$DB_PATH" "$SQL_COMMANDS"

# Check if the commands were executed correctly
if [ $? -eq 0 ]; then
    echo "Changes applied successfully."
else
    echo "Error applying changes."
    exit 1
fi

# Check the contents of the database
echo "Verifying users in the database..."
sudo sqlite3 "$DB_PATH" "SELECT * FROM users;"

The line

DB_PATH="/home/pi/videosurveillance/data/videosurveillance_db.sqlite"

defines the location of the SQLite file that contains the system data.

The block

if [ ! -f "$DB_PATH" ]; then
    echo "Error: Database does not exist at the specified path: $DB_PATH"
    exit 1
fi

checks the existence of the database. If the file does not exist in the specified path, the script stops running with an error message.

The block

SQL_COMMANDS="
DELETE FROM users WHERE id != 1;
UPDATE users
SET username = 'admin',
    password = '$pbkdf2-sha256\$29000$RwiBcO4dY4wRAoAQwjjnnA\$9iWG5vcAalsIN8tfWNHZj/nJausfezdDa16YzeabKcs',
    is_admin = 1
WHERE id = 1;
"

defines two SQL commands:

  • DELETE FROM users WHERE id != 1;
    delete all users except the one with id = 1.
  • UPDATE users … WHERE id = 1;
    update the user with id = 1, resetting the username to admin and the password to a default one (in PBKDF2 hash format for security). The default password is 123456.

SQL commands are executed on the database file using the sqlite3 command:

sudo sqlite3 "$DB_PATH" "$SQL_COMMANDS"

If the SQL command execution is successful ($? -eq 0), a confirmation message is displayed. Otherwise, the script exits with an error:

if [ $? -eq 0 ]; then
    echo "Changes applied successfully."
else
    echo "Error applying changes."
    exit 1
fi

The next block performs a final check of the database contents:

echo "Verifying users in the database..."
sudo sqlite3 "$DB_PATH" "SELECT * FROM users;"

When and why to use it 🔑

  • Reset credentials: if the administrator loses access to the system or forgets the credentials.
  • Database cleanup: if you want to quickly delete all users except the administrative one.
  • Debugging or maintenance: to check the database consistency and restore the default admin user.

Safety Notes 🔒

  • The password set in the script is default and static (123456), so it is advisable to change it immediately after the reset via the system interface.
  • The script requires sudo privileges because it accesses the SQLite database file and modifies it directly.
  • The SELECT * FROM users; command may reveal sensitive data in the console; better to run the script only on secure terminals.

Usage

The command line to run the reset_admin_db.sh script is:

sudo ./reset_admin_db.sh

Note: it is important to use sudo as the script requires elevated privileges to modify the database.

The restore_backup.sh script

The restore_backup.sh script is intended to restore backup data (photos, videos and databases) of the video surveillance system. During the process:

  • Stops the videosurveillance service.
  • Checks for the existence of a backup file (backup.zip) in the videosurveillance project folder.
  • Makes a temporary copy of the current database (if any).
  • Deletes existing data (photos, videos and database) and replaces them with the data in the backup.
  • Restores the main folders and assigns the correct permissions to the files.
  • Restarts the videosurveillance service when the process is complete.
#!/bin/bash

# Name of the script: restore_backup.sh
# Purpose: Restore backup of video surveillance system

# Variables
PROJECT_DIR=$(pwd)  # The directory where the script is executed
BACKUP_FILE="$PROJECT_DIR/backup.zip"
TMP_DIR="$PROJECT_DIR/restore_tmp"
LOG_FILE="$PROJECT_DIR/restore_backup.log"

# Log function
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}

# Initial confirmation
echo "You are about to restore a backup. Your current data will be replaced."
read -p "Do you want to proceed? [y/N]: " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
    echo "Restore canceled."
    exit 1
fi

# Checking for the existence of the backup.zip file
if [[ ! -f "$BACKUP_FILE" ]]; then
    log "Error: The backup file $BACKUP_FILE does not exist."
    exit 1
fi

# Stop the service
log "Service shutdown videosurveillance.service..."
sudo systemctl stop videosurveillance.service || {
    log "Error stopping service."
    exit 1
}

# Temporary backup of the current database
log "Creating a temporary backup of the current database..."
if [[ -f "$PROJECT_DIR/data/videosurveillance_db.sqlite" ]]; then
    cp "$PROJECT_DIR/data/videosurveillance_db.sqlite" "$PROJECT_DIR/data/videosurveillance_db.sqlite.bak"
    log "Backup of existing database completed."
else
    log "No current database found, skipping backup."
fi

# Deleting existing folders
log "Erasing the contents of the photos, videos and data folders..."
rm -rf "$PROJECT_DIR/photos/*" "$PROJECT_DIR/videos/*" "$PROJECT_DIR/data/*" || {
    log "Error deleting folders."
    exit 1
}

# Unzip to a temporary folder
log "Unzip backup file to temporary folder $TMP_DIR..."
mkdir -p "$TMP_DIR"
unzip -q "$BACKUP_FILE" -d "$TMP_DIR" || {
    log "Error while unzipping backup file."
    rm -rf "$TMP_DIR"
    exit 1
}

# Copying content to root folders
log "Copying the restored content to the main folders..."
cp -r "$TMP_DIR/photos/"* "$PROJECT_DIR/photos/"
cp -r "$TMP_DIR/videos/"* "$PROJECT_DIR/videos/"
cp -r "$TMP_DIR/data/"* "$PROJECT_DIR/data/"

# Assigning root permissions
log "Assigning root owner to restored files..."
sudo chown -R root:root "$PROJECT_DIR/photos" "$PROJECT_DIR/videos" "$PROJECT_DIR/data" || {
    log "Error assigning permissions."
    exit 1
}

# Cleaning the temporary folder
log "Cleaning the temporary folder $TMP_DIR..."
rm -rf "$TMP_DIR"

# Restarting the service
log "Restarting the service videosurveillance.service..."
sudo systemctl start videosurveillance.service || {
    log "Error starting service."
    exit 1
}

log "Restore completed successfully!"
exit 0

Initially the variables are initialized:

PROJECT_DIR=$(pwd)  
BACKUP_FILE="$PROJECT_DIR/backup.zip"
TMP_DIR="$PROJECT_DIR/restore_tmp"
LOG_FILE="$PROJECT_DIR/restore_backup.log"

where

  • PROJECT_DIR: the current working directory.
  • BACKUP_FILE: backup file path (backup.zip).
  • TMP_DIR: temporary directory for backup extraction.
  • LOG_FILE: log file to record script events.

The function

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}

logs messages to log file with timestamp.

User must confirm to start restore. If not confirmed (y/Y), script stops:

echo "You are about to restore a backup. Your current data will be replaced."
read -p "Do you want to proceed? [y/N]: " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
    echo "Restore canceled."
    exit 1
fi

If the backup.zip file does not exist, the script fails with an error message:

if [[ ! -f "$BACKUP_FILE" ]]; then
    log "Error: The backup file $BACKUP_FILE does not exist."
    exit 1
fi

Stops the videosurveillance service. If the stop fails, the script aborts:

log "Service shutdown videosurveillance.service..."
sudo systemctl stop videosurveillance.service || {
    log "Error stopping service."
    exit 1
}

Performs a temporary backup of the current database (if it exists):

if [[ -f "$PROJECT_DIR/data/videosurveillance_db.sqlite" ]]; then
    cp "$PROJECT_DIR/data/videosurveillance_db.sqlite" "$PROJECT_DIR/data/videosurveillance_db.sqlite.bak"
    log "Backup of existing database completed."
else
    log "No current database found, skipping backup."
fi

Then all files in the photos, videos and data folders are deleted:

log "Erasing the contents of the photos, videos and data folders..."
rm -rf "$PROJECT_DIR/photos/*" "$PROJECT_DIR/videos/*" "$PROJECT_DIR/data/*" || {
    log "Error deleting folders."
    exit 1
}

A temporary folder (restore_tmp) is then created and the contents of the backup.zip file are extracted into it:

log "Unzip backup file to temporary folder $TMP_DIR..."
mkdir -p "$TMP_DIR"
unzip -q "$BACKUP_FILE" -d "$TMP_DIR" || {
    log "Error while unzipping backup file."
    rm -rf "$TMP_DIR"
    exit 1
}

The script then copies the restored contents into the main project folders (photos, videos, data):

log "Copying the restored content to the main folders..."
cp -r "$TMP_DIR/photos/"* "$PROJECT_DIR/photos/"
cp -r "$TMP_DIR/videos/"* "$PROJECT_DIR/videos/"
cp -r "$TMP_DIR/data/"* "$PROJECT_DIR/data/"

and assigns the root owner and correct permissions to the restored files:

log "Assigning root owner to restored files..."
sudo chown -R root:root "$PROJECT_DIR/photos" "$PROJECT_DIR/videos" "$PROJECT_DIR/data" || {
    log "Error assigning permissions."
    exit 1
}

The temporary folder is then deleted:

log "Cleaning the temporary folder $TMP_DIR..."
rm -rf "$TMP_DIR"

The script restarts the videosurveillance service. If the restart fails, it displays an error and aborts.

log "Restarting the service videosurveillance.service..."
sudo systemctl start videosurveillance.service || {
    log "Error starting service."
    exit 1
}

The script ends with a success message and ends:

log "Restore completed successfully!"
exit 0

Usage

To launch the script, simply give the command:

sudo ./restore_backup.sh

⚠️Important! Restore a specific backup you downloaded earlier

When you run the restore_backup.sh script, it automatically restores the backup.zip file in the main project folder (/home/pi/videosurveillance). However, if you downloaded a previous backup and want to restore that one, follow these steps:

Transfer the backup.zip file to the Raspberry Pi

The procedure is the same as the one you already used to transfer the videosurveillance.zip file during the initial installation of the system. You can choose the method you prefer:

From Linux/macOS (recommended): use rsync for safe and reliable transfer. Here is the command:

rsync -avzP /path/to/your/backup.zip [email protected]:/home/pi/videosurveillance/

From Windows: follow the same procedure already explained with WinSCP to upload backup.zip to the /home/pi/videosurveillance/ directory.

Both rsync and WinSCP will overwrite the backup.zip file if there is already one in the destination directory (/home/pi/videosurveillance/). This means that the new backup.zip file will replace the existing one.

If you want to avoid accidentally overwriting an existing backup, you can rename the destination file before transferring, for example:

mv /home/pi/videosurveillance/backup.zip /home/pi/videosurveillance/backup_old.zip

Once the transfer is complete, check for the backup.zip file in the project folder (/home/pi/videosurveillance/):

ls -la /home/pi/videosurveillance/backup.zip

If you see it in the list, you are ready to restore!

Run the restore_backup.sh script:

./restore_backup.sh

The script will erase the current data and restore the backup from the newly transferred backup.zip file.

The soft_clean_docker.sh script

The soft_clean_docker.sh script performs a light cleanup of unused Docker resources, without deleting active containers or volumes. It is useful for keeping the Docker system clean and reclaiming disk space without interrupting running services or containers.

#!/bin/bash

read -p "Do you really want to do a soft clean? (y/n): " confirmation
if [[ $confirmation != "y" ]]; then
    echo "Cleaning cancelled."
    exit 0
fi


echo "Running the Docker soft clean..."

# Initial state
echo "### Initial state of Docker resources ###"
docker system df

# Delete stopped containers
docker container prune -f

# Delete images not associated with active containers
docker image prune -f

# Delete unused networks
docker network prune -f

# Delete unused volumes
docker volume prune -f

# Final state
echo "### Final state of Docker resources ###"
docker system df

echo "Soft cleanup completed. In-use resources preserved."

The block

read -p "Do you really want to do a soft clean? (y/n): " confirmation
if [[ $confirmation != "y" ]]; then
    echo "Cleaning cancelled."
    exit 0
fi

asks the user for confirmation before proceeding. If the user does not respond y, the script stops without doing anything.

The lines

echo "### Initial state of Docker resources ###"
docker system df

show the current state of Docker resources (containers, images, volumes, and networks).

The line

docker container prune -f

deletes all containers that are not running.

The lines

docker image prune -f
docker network prune -f
docker volume prune -f

respectively delete unused images, unused networks and unused volumes, keeping only images connected to active containers.

The script then shows the state of Docker resources after the cleanup to compare it with the initial one:

echo "### Final state of Docker resources ###"
docker system df

The script ends with a confirmation that the cleanup was successful, specifying that the resources in use have been preserved:

echo "Soft cleanup completed. In-use resources preserved."

Usage

To launch the script, simply give the command:

sudo ./soft_clean_docker.sh

The install_docker.sh script

This script prepares the Raspberry Pi to properly run the video surveillance system by performing:

  1. Changed swap configuration to increase available virtual memory, useful when building heavy packages like numpy.
  2. Installed Docker and Docker Compose, which are essential for running the project’s backend and frontend containers.
  3. Added the current user to the Docker group, so as to avoid using sudo to run Docker commands.
#!/bin/bash

# Install sqlite3 and python3-flask
sudo apt update && sudo apt install -y sqlite3  python3-flask

# Increase swap to 2048 MB (change as needed)
SWAPSIZE=2048
echo "Configuring swap size to $SWAPSIZE MB..."

# Edit the dphys-swapfile configuration file
sudo sed -i "s/^CONF_SWAPSIZE=.*/CONF_SWAPSIZE=$SWAPSIZE/" /etc/dphys-swapfile

# Restart the swap service
sudo systemctl stop dphys-swapfile
sudo systemctl start dphys-swapfile

echo "Swap configuration completed. Current swap status:"
free -h


echo "Checking and installing Docker and Docker Compose if necessary..."

# Check if Docker is installed
if ! [ -x "$(command -v docker)" ]; then
    echo "Docker not found. Installing Docker..."
    curl -sSL https://get.docker.com | sh
else
    echo "Docker is already installed."
    docker --version
fi

# Adding the current user to the Docker group (if not already present)
if groups $USER | grep &>/dev/null "\bdocker\b"; then
    echo "User is already part of the 'docker' group."
else
    echo "Adding user to the 'docker' group..."
    sudo usermod -aG docker $USER
    echo "You must reboot your system for changes to take effect."
    echo "After rebooting, run the 'setup_videosurveillance.sh' script."
fi

# Check if Docker Compose is installed
if ! [ -x "$(command -v docker-compose)" ]; then
    echo "Docker Compose not found. Installing Docker Compose..."
    sudo apt update && sudo apt install -y docker-compose
else
    echo "Docker Compose is already installed."
    docker-compose --version
fi

echo "Docker installation and configuration completed."
echo "Please reboot your system and then run the setup script."

The block

# Install sqlite3 and python3-flask
sudo apt update && sudo apt install -y sqlite3  python3-flask

SWAPSIZE=2048
echo "Configuring swap size to $SWAPSIZE MB..."
sudo sed -i "s/^CONF_SWAPSIZE=.*/CONF_SWAPSIZE=$SWAPSIZE/" /etc/dphys-swapfile
sudo systemctl stop dphys-swapfile
sudo systemctl start dphys-swapfile
echo "Swap configuration completed. Current swap status:"
free -h
  • Installs sqlite3 and python3-flask
  • Increases the swap size to 2048 MB.
  • Restarts the dphys-swapfile service to apply the new configuration.
  • Checks the current state of the swap using the free -h command.

Note: more swap helps avoid blocking when compiling complex Python modules.

The block

if ! [ -x "$(command -v docker)" ]; then
    echo "Docker not found. Installing Docker..."
    curl -sSL https://get.docker.com | sh
else
    echo "Docker is already installed."
    docker --version
fi

checks if Docker is already installed. If not, downloads and installs it using the official Docker script.

The next block

if groups $USER | grep &>/dev/null "\bdocker\b"; then
    echo "User is already part of the 'docker' group."
else
    echo "Adding user to the 'docker' group..."
    sudo usermod -aG docker $USER
    echo "You must reboot your system for changes to take effect."
    echo "After rebooting, run the 'setup_videosurveillance.sh' script."
fi

checks if the user is already in the Docker group. If not, add the user to the docker group and inform that a reboot is required for the change to take effect.

The block

if ! [ -x "$(command -v docker-compose)" ]; then
    echo "Docker Compose not found. Installing Docker Compose..."
    sudo apt update && sudo apt install -y docker-compose
else
    echo "Docker Compose is already installed."
    docker-compose --version
fi

checks for Docker Compose and installs it if necessary.

The script ends with some messages that inform the user that the installation has been completed and invites him to reboot the Raspberry:

echo "Docker installation and configuration completed."
echo "Please reboot your system and then run the setup script."

Usage

To launch the script execute the command

sudo ./install_docker.sh

At the end you need to reboot the Raspberry Pi as indicated at the end of the script. Once the system has rebooted, run the setup_videosurveillance.sh script to complete the project installation.

The setup_videosurveillance.sh script

This script is intended to complete the setup of the Video Surveillance project on the Raspberry Pi after Docker has been successfully installed. It takes care of:

  • Install essential packages to build advanced Python modules.
  • Configure permissions and owners for project files and directories.
  • Create and configure Docker containers defined in docker-compose.yml.
  • Install Flask for the host_service.py service.
  • Copy the systemd service files to the correct directory.
  • Enable and start the necessary services (videosurveillance.service and host_service.service).
#!/bin/bash


# These packages are used to compile advanced Python modules, especially when dealing with libraries 
# that require C/C++ components.
echo "installing packages used to compile advanced Python modules"
sudo apt install gcc python3-dev libffi-dev build-essential -y


# Set owner and permissions for specific folders
echo "setting owner and permissions for specific folders"
sudo chown -R root:root data photos videos __pycache__
sudo chmod -R 755 photos videos data __pycache__

# Specific permissions for files inside 'data', 'photos', and 'videos'
echo "setting permissions for files inside data, photos, and videos folders"
find ./data -type f -exec sudo chmod 644 {} \;
find ./photos -type f -exec sudo chmod 644 {} \;
find ./videos -type f -exec sudo chmod 644 {} \;

# Ensure that log and backup files are owned by pi and have appropriate permissions
find ./ -name "*.log" -exec sudo chown pi:pi {} \; -exec sudo chmod 644 {} \;
find ./ -name "backup.zip" -exec sudo chown pi:pi {} \; -exec sudo chmod 644 {} \;



# Name of services
VIDEOSURVEILLANCE_SERVICE="videosurveillance.service"
HOST_SERVICE="host_service.service"

# Service file path
VIDEOSURVEILLANCE_FILE="./$VIDEOSURVEILLANCE_SERVICE"
HOST_SERVICE_FILE="./$HOST_SERVICE"

# systemd path to service files
SYSTEMD_PATH="/etc/systemd/system"

# Cleaning and disposal of unused containers
echo "Cleaning of existing containers..."
docker ps -a --filter "name=videosurveillance" --format "{{.ID}}" | xargs -r docker rm -f
echo "Existing containers removed."

# Creation of containers
echo "Creating containers defined in docker-compose.yml..."
docker-compose up --no-start
echo "Containers successfully created."

echo "Installing Flask and necessary dependencies for the host_service.py service..."
pip install Flask

# Copy of video surveillance service file
echo "Copying the service file $VIDEOSURVEILLANCE_SERVICE in $SYSTEMD_PATH..."
if [ -f "$VIDEOSURVEILLANCE_FILE" ]; then
    sudo cp "$VIDEOSURVEILLANCE_FILE" "$SYSTEMD_PATH/$VIDEOSURVEILLANCE_SERVICE"
    echo "Service file $VIDEOSURVEILLANCE_SERVICE copied successfully."
else
    echo "Error: The file $VIDEOSURVEILLANCE_FILE does not exist. Aborting."
    exit 1
fi

# Copy of the host_service service file
echo "Copying the service file $HOST_SERVICE in $SYSTEMD_PATH..."
if [ -f "$HOST_SERVICE_FILE" ]; then
    sudo cp "$HOST_SERVICE_FILE" "$SYSTEMD_PATH/$HOST_SERVICE"
    echo "Service file $HOST_SERVICE copied successfully."
else
    echo "Error: File $HOST_SERVICE_FILE does not exist. Aborting."
    exit 1
fi

# Reload systemd services and enable services
echo "I reload systemd, enable and start services..."
sudo systemctl daemon-reload
sudo systemctl enable "$VIDEOSURVEILLANCE_SERVICE"
sudo systemctl enable "$HOST_SERVICE"
sudo systemctl start "$VIDEOSURVEILLANCE_SERVICE"
sudo systemctl start "$HOST_SERVICE"

# Final notice
echo "Setup complete! The following services have been configured and started:"
echo "1. $VIDEOSURVEILLANCE_SERVICE"
echo "2. $HOST_SERVICE"
echo ""
echo "You can manage services with the following commands:"
echo "sudo systemctl enable/disable/start/stop/restart/status videosurveillance.service"
echo "sudo systemctl enable/disable/start/stop/restart/status host_service.service"

The command

echo "installing packages used to compile advanced Python modules"
sudo apt install gcc python3-dev libffi-dev build-essential -y

installs the packages needed to build Python modules that require C/C++ components (e.g. numpy and cryptography).

This block

sudo chown -R root:root data photos videos __pycache__
sudo chmod -R 755 photos videos data __pycache__
find ./data -type f -exec sudo chmod 644 {} \;
find ./photos -type f -exec sudo chmod 644 {} \;
find ./videos -type f -exec sudo chmod 644 {} \;
find ./ -name "*.log" -exec sudo chown pi:pi {} \; -exec sudo chmod 644 {} \;
find ./ -name "backup.zip" -exec sudo chown pi:pi {} \; -exec sudo chmod 644 {} \;

sets root:root as the owner for the data, photos, videos, and pycache directories, and assigns 755 permissions to the directories and 644 permissions to the files within data, photos, and videos. It also ensures that the .log and backup.zip files are owned by the pi user and have 644 permissions.

The next block

docker ps -a --filter "name=videosurveillance" --format "{{.ID}}" | xargs -r docker rm -f
docker-compose up --no-start

removes any existing containers related to the project and creates new containers defined in the docker-compose.yml file without starting them.

Flask is then installed, which is necessary for the host_service.py service to work:

pip install Flask

The videosurveillance.service and host_service.service service files are then copied to the /etc/systemd/system directory, making them manageable via systemd:

VIDEOSURVEILLANCE_SERVICE="videosurveillance.service"
HOST_SERVICE="host_service.service"
SYSTEMD_PATH="/etc/systemd/system"

sudo cp "$VIDEOSURVEILLANCE_FILE" "$SYSTEMD_PATH/$VIDEOSURVEILLANCE_SERVICE"
sudo cp "$HOST_SERVICE_FILE" "$SYSTEMD_PATH/$HOST_SERVICE"

The next block

sudo systemctl daemon-reload
sudo systemctl enable "$VIDEOSURVEILLANCE_SERVICE"
sudo systemctl enable "$HOST_SERVICE"
sudo systemctl start "$VIDEOSURVEILLANCE_SERVICE"
sudo systemctl start "$HOST_SERVICE"

reloads systemd services to register the new configuration files. Enables and starts the videosurveillance.service and host_service.service services.

The script ends with a confirmation message that notifies the user that the configuration is complete and the services are active:

echo "Setup complete! The following services have been configured and started:"

Usage

💻 Prerequisites
  • Docker and Docker Compose must already be installed. If they are not, use the install_docker.sh script to prepare them.
  • Make sure you are in the project directory (/home/pi/videosurveillance) before running the script.
⚙️ Running the script

To run the script, use the following command in the terminal:

sudo ./setup_videosurveillance.sh

🔑 Note: the script requires administrator permissions (sudo) as it modifies system files and starts systemd services.

The install_wireguard.sh script

The install_wireguard.sh script automates the process of installing and configuring WireGuard on Raspberry Pi. It allows you to set up a VPN server, automatically generate client configuration, and provide a QR code for quick setup on your smartphone.

Let’s now see how it works in detail.

Introduction and initial message

#!/bin/bash

echo "Installing and configuring WireGuard..."

Defines the script as a bash executable (#!/bin/bash). Prints a message to indicate that installation is in progress.

Get the public IP of the server

SERVER_IP=$(curl -s -4 https://ifconfig.me)

Uses curl to get the public IP of the connection, which is needed for client configuration. The ifconfig.me command returns the current IP of the Raspberry as seen from the Internet.

📌 Note: if the user has a dynamic IP, he will need to set up a DDNS to update it automatically.

Generating security keys

SERVER_PORT=51820
SERVER_PRIVKEY=$(wg genkey)
SERVER_PUBKEY=$(echo "$SERVER_PRIVKEY" | wg pubkey)
CLIENT_PRIVKEY=$(wg genkey)
CLIENT_PUBKEY=$(echo "$CLIENT_PRIVKEY" | wg pubkey)
CLIENT_IP="10.0.0.2/32"

Automatically generates encryption keys for the server and client.

Defines the listening port 51820, which must be opened in the router.

Assigns the client the IP 10.0.0.2/32 in the VPN.

📌 Note: each client will have a unique IP within the VPN network.

Installing WireGuard (if not already present)

if ! command -v wg &> /dev/null; then
    sudo apt update
    sudo apt install -y wireguard qrencode
else
    echo "WireGuard is already installed."
fi

Checks if WireGuard is already installed (command -v wg).

If it is not installed, download and install it together with qrencode, which is needed to generate the client configuration QR code.

📌 Note: if WireGuard is already present, the script reports this and continues without reinstalling it.

Creating the server configuration

echo "Configuring the server..."
cat <<EOF | sudo tee /etc/wireguard/wg0.conf
[Interface]
Address = 10.0.0.1/24
PrivateKey = $SERVER_PRIVKEY
ListenPort = $SERVER_PORT
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT

[Peer]
PublicKey = $CLIENT_PUBKEY
AllowedIPs = $CLIENT_IP
EOF

Creates the /etc/wireguard/wg0.conf configuration file for the server.

Sets the VPN address to 10.0.0.1/24.

Sets port 51820 to accept connections.

Adds iptables rules to enable VPN traffic forwarding. Configures the client with its PublicKey and assigned IP.

📌 Note: PostUp and PostDown rules ensure that traffic can flow through the VPN.

Creating the client configuration

echo "Configuring the client..."
cat <<EOF > client.conf
[Interface]
PrivateKey = $CLIENT_PRIVKEY
Address = $CLIENT_IP
DNS = 8.8.8.8

[Peer]
PublicKey = $SERVER_PUBKEY
Endpoint = $SERVER_IP:$SERVER_PORT
AllowedIPs = 192.168.1.0/24
PersistentKeepalive = 25
EOF

Generates the client.conf configuration file for the client.

Sets private IP 10.0.0.2/32.

Sets DNS 8.8.8.8 (Google) for name resolution.

Specifies public IP of server and port 51820 to connect.

Configures AllowedIPs = 192.168.1.0/24 to access the internal network.

Adds PersistentKeepalive = 25 to keep the connection active.

📌 Note: the client.conf file can be imported directly to a PC or transformed into a QR code for smartphones.

Automatically start the WireGuard service

echo "Starting WireGuard..."
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0

Enables WireGuard at system startup (enable). Starts the service immediately (start).

📌 Note: if the Raspberry is rebooted, WireGuard will start automatically.

QR Code generation for client configuration

echo "Scan this QR code with the WireGuard app:"
qrencode -t UTF8 < client.conf

Generates a QR code based on the contents of client.conf.

The QR code can be scanned directly with the WireGuard app on Android and iOS.

📌 Note: this avoids having to manually copy the configuration to your smartphone.

Viewing client configuration in clear text

echo "Client configuration (for PC users):"
cat client.conf

Prints the contents of client.conf to screen, useful for those who want to manually copy the configuration to Windows/Linux/macOS PCs.

📌 Note: desktop users will need to manually import this file into the WireGuard client.

Restart the network to apply the changes

sudo systemctl restart networking

Restarts the network service to apply the new routing configurations.

📌 Note: a reboot of the Raspberry is still recommended.

Confirmation message and final instructions

echo "WireGuard has been successfully configured!"
echo "To manually restart WireGuard: sudo systemctl restart wg-quick@wg0"
echo "Please reboot your system..."

Confirms that WireGuard has been successfully installed.

Shows the command to manually restart the service if there is a problem.

It suggests a system reboot to ensure proper operation.

Port Note for WireGuard and Router Configuration

In order to access your Raspberry Pi remotely through WireGuard, you need to open a port in your router and redirect it to the Raspberry.
By default, WireGuard uses UDP port 51820, but not all routers support it.

🎯 How to choose the right port?

  • Check your router: if you try to set up port forwarding and get an error that the port is out of range, choose another port within 32768-40959.
  • Avoid ports already in use by other services: do not choose ports reserved for commercial VPNs, gaming servers, or VoIP.
  • Keep UDP: WireGuard only works with UDP, so do not try TCP.

In the script replace the port 51820 of the line

SERVER_PORT=51820

with the port of your choice.

📢 Useful commands for managing services

After running the script, you can manage services with these commands:

Check the status of a service:

sudo systemctl status videosurveillance.service

Start a service:

sudo systemctl start videosurveillance.service

Restart a service:

sudo systemctl restart videosurveillance.service

Stop a service:

sudo systemctl stop videosurveillance.service

The same operations are possible for the host_service.service service.

The configuration file config.ini

The config.ini configuration file contains the main configurations of the application, most of which can be modified via the configuration web page present in the frontend and accessible only to the administrator. The file looks like this:

[general]
init_delay = 15
process_interval = 10
max_photos = 500
batch_size = 50
max_videos = 50

[paths]
photos_dir = /app/photos/motion
videos_dir = /app/videos/motion
batch_upload_api_url = http://localhost:8000/upload_batch/
upload_video_api_url = http://localhost:8000/upload_video/
notify_api_url = http://localhost:8000/notify
photo_directory = photos
thumbnail_directory = photos/thumbnails
video_directory = videos
video_thumbnail_directory = videos/thumbnails

[security]
secret_key = mydefaultsecretkey

[notification]
pushover_enabled = false
pushover_token = 111111111111111111111111111111111
pushover_user_key = 22222222222222222222222222
telegram_enabled = false
telegram_token = 333333333333333333333
chatid_telegram = 444444444444
gmail_enabled = false
smtp_server = smtp.gmail.com
smtp_port = 587
sender_email = [email protected]
app_password = 55555555555555555
receiver_email = [email protected]

The “general” section

This section, editable from the configuration page, contains the configuration parameters that govern the general behavior of the monitoring script.

init_delay: sets the time interval (in seconds) between folder scans. This is to avoid processing incomplete files if Motion is still starting.
process_interval: sets the time interval (in seconds) between folder scans. The script waits process_interval seconds before checking Motion folders again.
max_photos: defines the maximum number of photos that can be stored in the system. If the number of photos exceeds max_photos, the oldest photos are automatically deleted.
batch_size: sets the maximum number of files to upload at once in a batch. This is to optimize the upload without overloading the system or server.
max_videos: defines the maximum number of videos that can be stored in the system. If the number of videos exceeds max_videos, the oldest videos are automatically deleted.

The “paths” section

This section defines the folder paths where photos and videos are saved, as well as the URLs of the APIs for uploading and notifications. This section is NOT configurable from the configuration page and is NOT manually configurable as it must remain as is.

[paths]
photos_dir = /app/photos/motion
videos_dir = /app/videos/motion
batch_upload_api_url = http://localhost:8000/upload_batch/
upload_video_api_url = http://localhost:8000/upload_video/
notify_api_url = http://localhost:8000/notify
photo_directory = photos
thumbnail_directory = photos/thumbnails
video_directory = videos
video_thumbnail_directory = videos/thumbnails

photos_dir: specifies the root folder where Motion saves captured photos. This folder is monitored by the motion_monitor.py script to process the images.
videos_dir: defines the folder where the videos recorded by Motion are saved. This folder is also monitored for file stability before uploading.
batch_upload_api_url: contains the URL of the REST API that handles batch uploading of photos.
upload_video_api_url: contains the URL of the REST API for uploading videos.
notify_api_url: API URL that notifies users when new files are uploaded.
photo_directory: defines the main folder for storing processed photos.
thumbnail_directory: defines the folder where photo thumbnails are saved.
video_directory: defines the main folder for storing processed videos.
video_thumbnail_directory: contains video thumbnails, which are generated by extracting a frame from the video.

The “security” section

This section contains security parameters that are essential for the system to function. It is the only section that must be modified directly by editing the file in the videosurveillance project folder, for example using the command:

nano config.ini

secret_key: defines the secret key used to generate and verify JWT (JSON Web Token) tokens. It is essential for authentication and API protection. It must be changed before putting the system into production, otherwise an attacker could manipulate the JWT tokens and gain unauthorized access.

The “notification” section

This section, editable from the configuration page, configures system notifications, which can be sent via Pushover, Telegram or Gmail.

Pushover Notifications

pushover_enabled = false
pushover_token = 111111111111111111111111111111111
pushover_user_key = 22222222222222222222222222

pushover_enabled: enables (true) or disable (false) Pushover notifications.

pushover_token: is the application token for Pushover authentication.

pushover_user_key: it is the user’s key to receive notifications.

Notifications via Telegram

telegram_enabled = false
telegram_token = 333333333333333333333
chatid_telegram = 444444444444

telegram_enabled: enables (true) or disable (false) notifications on Telegram.

telegram_token: it is the Telegram bot token used to send messages.

chatid_telegram: is the chat ID (can be a single user or a group).

Notifications via Gmail

gmail_enabled = false
smtp_server = smtp.gmail.com
smtp_port = 587
sender_email = [email protected]
app_password = 55555555555555555
receiver_email = [email protected]

gmail_enabled: enables (true) or disable (false) email notifications (Gmail SMTP).

smtp_server: it is Gmail’s SMTP server for sending emails.

smtp_port : 587 is the SMTP port for sending with TLS.

sender_email: is the sender email address (the account that sends the notifications).

app_password: this is the password generated for the app (to be created in Google security settings).

receiver_email: is the recipient’s email address (who receives the notification).

For the creation of the Pushover application, the Telegram bot and the Gmail application I refer you to the specific paragraphs.

Python scripts: the heart of the application

The video surveillance system is managed by four main scripts written in Python. These scripts interact with each other to monitor the camera, manage the database, process photos and videos, send notifications, and provide an API interface via FastAPI.
Below is an overview of each of them:

The main.py script: the heart of the backend

The main.py script is the heart of the application, providing the FastAPI-based backend. It is responsible for handling REST APIs, system configuration, user authentication, and many other operations.

Main features

  • User management: authentication with JWT, user creation, admin account management.
  • Media management: upload and download photos and videos, create thumbnails.
  • REST API: provides endpoints to interact with the web interface.
  • Automatic backup: periodically creates a backup file containing the database and all media files.
  • System statistics: real-time monitoring of CPU, RAM, disk and swap usage.
  • Notifications: sends notifications to users via Telegram, Pushover and email when new events are detected.
  • Dynamic configuration: reads and updates the config.ini and motion.conf configuration files.

Note: the script automatically starts a backup process that runs periodically every 10 minutes (default setting but changeable) at the line:

scheduler.add_job(create_backup, "interval", minutes=10)

The script performs the following functions:

Importing libraries

import configparser
from typing import List
import requests
from PIL import Image
from fastapi import FastAPI, File, UploadFile, HTTPException, Body, BackgroundTasks, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from database import get_db, Photo, Video, User
import shutil
import os
import smtplib
import subprocess
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from moviepy.editor import VideoFileClip
from passlib.context import CryptContext
import jwt
from pydantic import BaseModel
import psutil
from apscheduler.schedulers.background import BackgroundScheduler
import zipfile

FastAPI: REST API management framework.

SQLAlchemy: ORM to interact with the database.

psutil: to get system statistics (CPU, RAM, disk, swap).

jwt: to manage JWT authentication tokens.

PIL, MoviePy: to manage images and videos.

smtplib, email: to send notifications via email.

requests: to communicate with other APIs (e.g. Telegram, Pushover).

FastAPI configuration

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

FastAPI is the framework used to create the backend REST APIs.

CORS Middleware allows accepting requests from any domain (necessary for communication with the frontend).

Reading the configuration

config_file_path = '/app/config.ini'
motion_config_path = '/etc/motion/motion.conf'
BACKUP_PATH = "/app/backup.zip"
DB_PATH = "/app/data/videosurveillance_db.sqlite"

config = configparser.ConfigParser()
config.read(config_file_path)

config.ini contains the application settings.

motion.conf contains the motion detection software configuration.

The paths for the database and backup are defined.

Creating the necessary directories

PHOTO_DIRECTORY = config.get('paths', 'photo_directory')
if not os.path.exists(PHOTO_DIRECTORY):
    os.makedirs(PHOTO_DIRECTORY)

THUMBNAIL_DIRECTORY = config.get('paths', 'thumbnail_directory')
if not os.path.exists(THUMBNAIL_DIRECTORY):
    os.makedirs(THUMBNAIL_DIRECTORY)

VIDEO_DIRECTORY = config.get('paths', 'video_directory')
if not os.path.exists(VIDEO_DIRECTORY):
    os.makedirs(VIDEO_DIRECTORY)

VIDEO_THUMBNAIL_DIRECTORY = config.get('paths', 'video_thumbnail_directory')
if not os.path.exists(VIDEO_THUMBNAIL_DIRECTORY):
    os.makedirs(VIDEO_THUMBNAIL_DIRECTORY)

If the folders for photos, thumbnails and videos do not exist, they are created automatically.

Authentication with JWT

SECRET_KEY = config.get('security', 'secret_key')
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")

JWT is used to handle user authentication.

SECRET_KEY is the secret key to sign tokens.

pwd_context is used for password hashing.

🔐 What is SECRET_KEY and why is it important to change it?

In the main.py file, the SECRET_KEY variable is used to sign and validate JWT (JSON Web Token) tokens, which are used to authenticate users in the video surveillance system.

📌 What is SECRET_KEY used for?

SECRET_KEY is a fundamental element for system security because:

  1. Protects JWT tokens: every time a user logs in, the server generates a JWT token containing their data and signs it with SECRET_KEY. The client (browser, app) then uses this token to authenticate itself in all subsequent requests.
  2. Prevents token forgery: if an attacker were to generate a valid JWT token, they could access the system as if they were an authorized user. The secret key prevents this from happening.
  3. Ensures user and API security: without a secure key, an attacker could intercept or create malicious tokens to access the system.

⚠️ Why is it dangerous to leave SECRET_KEY with the default value?

There is a security check in the code:

SECRET_KEY = config.get('security', 'secret_key')
if SECRET_KEY == "mydefaultsecretkey":
    print("[WARNING] The SECRET_KEY has not been changed! Change it to secure the system.")

If the user does not change this key, it means that anyone who knows the application code can generate valid JWT tokens and log in to the system without needing a password!

In practice:

  • If an attacker discovers that the SECRET_KEY value is the default (mydefaultsecretkey), they can create valid JWT tokens and authenticate themselves as an administrator.
  • This completely compromises the security of the system, allowing unauthorized access.

🔄 How to change SECRET_KEY to protect system?

To avoid security issues, user should change SECRET_KEY inside config.ini file.

👉 Open the configuration file (config.ini) and find the [security] section:

[security]
secret_key = mydefaultsecretkey  

🔑 Replace the SECRET_KEY value with a long, random string.
Example of a secure key:

secret_key = wF2@pL6z!9vQmN7s#XyT$3rKdG8B

You can generate a secure key using Python:

import secrets
print(secrets.token_hex(32))  

Automatic backup

def create_backup():
    print("[INFO] SYSTEM BACKUP")
    db_file = "/app/data/videosurveillance_db.sqlite"
    db_dump_path = "/app/db_dump.db"
    shutil.copyfile(db_file, db_dump_path)

    with zipfile.ZipFile(BACKUP_PATH, 'w') as backup_zip:
        for folder_name, _, filenames in os.walk(PHOTO_DIRECTORY):
            for filename in filenames:
                file_path = os.path.join(folder_name, filename)
                arcname = os.path.join("photos", filename)
                backup_zip.write(file_path, arcname)

        for folder_name, _, filenames in os.walk(VIDEO_DIRECTORY):
            for filename in filenames:
                file_path = os.path.join(folder_name, filename)
                arcname = os.path.join("videos", filename)
                backup_zip.write(file_path, arcname)

        backup_zip.write(db_file, os.path.join("data", os.path.basename(db_file)))

scheduler = BackgroundScheduler()
scheduler.add_job(create_backup, "interval", minutes=10)
scheduler.start()

create_backup()

A backup is created every 10 minutes, saving photos, videos and databases in backup.zip.

Class for user delete request

This class defines the structure of the JSON request to delete a user:

class DeleteUserRequest(BaseModel):
    username: str

The DeleteUserRequest class extends Pydantic’s BaseModel. It is used to validate requests to delete a user, ensuring that they have at least the username field.

Recovering the authenticated user

This function verifies the JWT token and returns the authenticated user:

def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
    try:
        print(f"Token received: {token}")  # Debug
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        print(f"Decoded payload: {payload}")  # Debug
        if username is None:
            raise HTTPException(status_code=401, detail="Invalid token")
        user = db.query(User).filter(User.username == username).first()
        if not user:
            raise HTTPException(status_code=404, detail="User not found")
        return user
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except PyJWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

Decodes the JWT token received in the request.

Extracts the sub value representing the username of the user.

If the token is expired or invalid, an HTTP exception is raised.

If the user exists in the database, it is returned.

Creating a password hash

This function converts a password into a secure format with a hashing algorithm:

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

Uses Passlib to generate a hash of the password. The hash is used to protect the credentials stored in the database.

Creating the JWT token

This function generates a JWT access token that will be used to authenticate requests:

def create_access_token(data: dict):
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

data is a dictionary with user data.

The token has a default expiration of 30 minutes.

Uses HS256 as the signature algorithm with SECRET_KEY to ensure security.

Creating the default Admin user

@app.on_event("startup")
def create_default_admin():
    db: Session = next(get_db())
    existing_admin = db.query(User).filter(User.username == "admin").first()
    if not existing_admin:
        admin = User(
            username="admin",
            password=hash_password("123456"),
            is_admin=True
        )
        db.add(admin)
        db.commit()
        print("[INFO] Admin user created with username 'admin' and password '123456'")
    else:
        print("[INFO] Admin user already present")

When the app starts, it checks if an admin user already exists. If not, it creates one with:

  • Username: "admin"
  • Password: "123456" (hashed)
  • Role: Admin

If the admin already exists, prints an informational message.

Video validation

This feature checks whether a video is valid before it is saved:

def is_video_valid(video_path):
    try:
        with VideoFileClip(video_path) as clip:
            duration = clip.duration
            if duration <= 0:
                print(f"[VALIDATION] Invalid duration (<=0) for video: {video_path}")
                return False
            print(f"[VALIDATION] Valid duration: {duration} seconds for video {video_path}")
            return True
    except Exception as e:
        print(f"[VALIDATION] Error during video validation {video_path}: {e}")
        return False

Opens the video file with MoviePy.

Checks the duration:

  • If <= 0 seconds, the file is corrupt or invalid (or is at the beginning of the save).
  • If all goes well, returns True.

If the file cannot be opened, returns False.

Backup download

API endpoint to download backup file:

@app.get("/download-backup")
def download_backup():
    if os.path.exists(BACKUP_PATH):
        return FileResponse(BACKUP_PATH, media_type="application/zip", filename="backup.zip")
    return {"error": "Backup file not found"}

Checks if the backup.zip file exists.

If it does, it returns as a downloadable file. If it doesn’t, it returns an error message.

System statistics

Endpoint to collect real-time system resource information:

@app.get("/system-stats")
def get_system_stats():
    try:
        cpu_usage = psutil.cpu_percent(interval=1)
        memory = psutil.virtual_memory()
        memory_total = memory.total / (1024 ** 2)
        memory_used = memory.used / (1024 ** 2)
        memory_percent = memory.percent
        disk = psutil.disk_usage('/')
        disk_total = disk.total / (1024 ** 3)
        disk_used = disk.used / (1024 ** 3)
        disk_percent = disk.percent
        swap = psutil.swap_memory()
        swap_total = swap.total / (1024 ** 2)
        swap_used = swap.used / (1024 ** 2)
        swap_percent = swap.percent

        return {
            "cpu_usage_percent": cpu_usage,
            "memory": {
                "total_mb": memory_total,
                "used_mb": memory_used,
                "percent": memory_percent,
            },
            "disk": {
                "total_gb": disk_total,
                "used_gb": disk_used,
                "percent": disk_percent,
            },
            "swap": {
                "total_mb": swap_total,
                "used_mb": swap_used,
                "percent": swap_percent,
            }
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error collecting system statistics: {str(e)}")

CPU: percentage of usage in the last seconds.

RAM: total amount, used and percentage of usage.

Disk: total space, used and percentage of usage.

Swap: virtual memory used and available.

Photo and Video Statistics

Endpoint that collects information about stored media:

@app.get("/media-stats")
def media_stats(db: Session = Depends(get_db)):
    import datetime
    from pathlib import Path

    today = datetime.datetime.now()
    last_month = today - datetime.timedelta(days=30)

    photos = db.query(Photo).all()
    total_photos = len(photos)
    recent_photos = [p for p in photos if datetime.datetime.strptime(p.filename.split("-")[1], "%Y%m%d%H%M%S") > last_month]
    
    photo_size_mb = sum(Path(p.filepath).stat().st_size for p in photos) / (1024 * 1024) if photos else 0

    return {
        "photos": {
            "total_count": total_photos,
            "last_month_count": len(recent_photos),
            "total_size_mb": round(photo_size_mb, 2),
        }
    }

Counts the number of photos and videos saved.

Calculates the space occupied in MB.

Counts how many files have been added in the last month.

User login

This endpoint manages user login, verifying username and password:

@app.post("/login")
def login(username: str = Body(...), password: str = Body(...), db: Session = Depends(get_db)):
    user = db.query(User).filter(User.username == username).first()
    if not user or not pwd_context.verify(password, user.password):
        raise HTTPException(status_code=400, detail="Invalid username or password")
    
    # Add is_admin to the payload
    access_token = create_access_token(data={"sub": user.username, "is_admin": user.is_admin})
    return {"access_token": access_token, "token_type": "bearer"}

Checks if the username exists in the database.

Checks your password using the Passlib form.

If the password is incorrect, it returns an error 400 – Invalid username or password.

If the data is correct, generates a JWT token containing:

  • sub: the username.
  • is_admin: a boolean value indicating whether the user is admin.

Returns the JWT token to use in future requests.

Adding a new user

Only a user with the admin role can create a new account.

@app.post("/users/add")
def add_user(
    username: str = Body(...),
    password: str = Body(...),
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Accesso negato: solo l'admin può aggiungere utenti.")
    
    existing_user = db.query(User).filter(User.username == username).first()
    if existing_user:
        raise HTTPException(status_code=400, detail="Username già in uso.")

    hashed_password = pwd_context.hash(password)
    new_user = User(username=username, password=hashed_password, is_admin=False)
    db.add(new_user)
    db.commit()
    return {"info": f"Utente '{username}' aggiunto con successo"}

Checks if the user making the request is admin.

If the username already exists, it returns an error 400 – Username già in uso.

If the name is available:

  • Password hash with Passlib.
  • Creates new user in database.

Confirms with a success message.

Editing a user

The admin can update a user’s credentials:

@app.put("/users/update/{user_id}")
def update_user(
    user_id: int,
    username: str = Body(None),
    password: str = Body(None),
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Accesso negato: solo l'admin può modificare utenti.")

    user = db.query(User).filter(User.id == user_id).first()
    if not user or user.is_admin:
        raise HTTPException(status_code=404, detail="Utente non trovato o non modificabile.")
    
    if username:
        user.username = username
    if password:
        user.password = pwd_context.hash(password)

    db.commit()
    return {"info": f"User with ID {user_id} updated"}

Only an admin can edit users.

If the user does not exist or is an admin, it returns an error 404 – Utente non modificabile.

Allows you to change the username and password of a normal user.

Saves changes to the database.

Change password

Allows a user to change their password:

@app.post("/users/change-password")
def change_password(
    current_password: str = Body(...),
    new_password: str = Body(...),
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    if not pwd_context.verify(current_password, current_user.password):
        raise HTTPException(status_code=400, detail="Password attuale errata")
    
    current_user.password = pwd_context.hash(new_password)
    db.commit()
    return {"info": "Password aggiornata con successo"}

Verifies that the current password is correct.

If incorrect, it returns an error 400 – Password attuale errata.

If correct, updates the password with a new hash.

Confirms with a success message.

Updating Admin Credentials

Only the admin can update their username and password:

@app.put("/admin/update")
def update_admin(
    current_password: str = Body(...),
    new_username: str = Body(None),
    new_password: str = Body(None),
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Accesso negato: solo l'admin può aggiornare i propri dati.")
    
    if not pwd_context.verify(current_password, current_user.password):
        raise HTTPException(status_code=400, detail="Password attuale errata")
    
    if new_username:
        current_user.username = new_username
    if new_password:
        current_user.password = pwd_context.hash(new_password)

    db.commit()
    return {"info": "Admin data updated successfully"}

Checks if the user is admin.

If the current password is incorrect, it returns an error 400 – Password attuale errata.

Allows the admin to change username and password.

Saves changes to the database.

Deleting a user

Admin can only delete normal users, he cannot delete himself:

@app.delete("/users/delete/")
def delete_user(
    request: DeleteUserRequest,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Only admin can delete users")
    
    if current_user.username == request.username:
        raise HTTPException(status_code=403, detail="Admin cannot delete themselves")
    
    user = db.query(User).filter(User.username == request.username).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    
    db.delete(user)
    db.commit()
    return {"message": f"User '{request.username}' deleted successfully"}

Only an admin can delete users.

The admin cannot delete himself.

If the user does not exist, it returns an error 404 – User not found.

If the user exists, it is deleted from the database.

User list

The admin can get the list of all registered users:

@app.get("/users")
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Accesso negato: solo l'admin può visualizzare gli utenti.")
    
    users = db.query(User).all()
    return [
        {
            "id": user.id,
            "username": user.username,
            "is_admin": user.is_admin
        }
        for user in users
    ]

Only the admin can access the user list.

If not admin, returns 403 – Accesso negato.

Returns the list of users with:

  • ID
  • Username
  • Role (Admin or normal).

Get system configuration

This endpoint returns the configuration parameters saved in the config.ini and motion.conf files:

@app.get("/config")
def get_config():
    config_data = {}

    # General configuration and notifications from config.ini
    config.read(config_file_path)  # Always reload the file
    config_data["general"] = dict(config.items("general"))
    config_data["notification"] = dict(config.items("notification"))

    # motion.conf configuration
    try:
        with open(motion_config_path, "r") as f:
            motion_config_content = f.readlines()
        config_data["motion"] = {"motion_conf_content": "".join(motion_config_content)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error reading motion.conf: {e}")

    print("Motion.conf returned to the frontend:")
    print(config_data["motion"]["motion_conf_content"])

    return config_data

Reads the config.ini file and extracts the general and notification sections.

Opens the motion.conf file to read the motion detection configuration parameters.

If the motion.conf file cannot be read, it returns a 500 error.

Returns configurations to the frontend in JSON format.

Configuration update

Allows you to change parameters in the config.ini and motion.conf files:

@app.post("/config")
def update_config(new_config: dict = Body(...)):
    try:
        print("Data received for update:", new_config)

        # General configuration update and notifications in config.ini
        for section, params in new_config.items():
            if section in ["general", "notification"]:
                if section not in config.sections():
                    config.add_section(section)
                for key, value in params.items():
                    config.set(section, key, str(value))
        with open(config_file_path, 'w') as configfile:
            config.write(configfile)
        
        # Reload to confirm saving
        config.read(config_file_path)
        print("Config.ini updated successfully.")

        # Updated motion.conf
        if "motion" in new_config and "motion_conf_content" in new_config["motion"]:
            motion_config_content = new_config["motion"]["motion_conf_content"]
            with open(motion_config_path, 'w') as motion_file:
                motion_file.write(motion_config_content)
            print("Motion.conf updated successfully.")

        return {"info": "Configuration updated successfully."}
    except Exception as e:
        print("Error during update:", e)
        raise HTTPException(status_code=500, detail=f"Error during update: {e}")

Receives the new parameters in a JSON object.

Updates the config.ini file, modifying the general and notification sections.

Overwrites the motion.conf file with the new parameters received.

If all goes well, it returns “Configuration updated successfully”.

If there is an error, returns 500 – Error during update.

Sending notifications

This endpoint handles sending notifications via different methods (Pushover, Email, Telegram):

@app.post("/notify")
def notify(title: str = Body(...), message: str = Body(...), image_path: str = Body(None)):
    # Load the updated configuration file
    config.read(config_file_path)
    
    # Check and send notification for each enabled method
    try:
        send_pushover_notification(title, message, image_path)
    except Exception as e:
        print(f"[Notification] Error during pushover notification {e}")

    try:
        send_mail_notification(title, message, image_path)
    except Exception as e:
        print(f"[Notification] Error during Gmail notification {e}")
    
    try:
        send_telegram_notification(title, message, image_path)
    except Exception as e:
        print(f"[Notification] Error during Telegram notification {e}")
    
    return {"info": "Notifications sent based on active settings"}

Reads the updated configuration to understand which notifications to send.

Sends notifications via:

  1. Pushover
  2. Email (Gmail)
  3. Telegram

If a notification fails, it prints an error to the log but continues with the other methods.

Returns “Notifications sent based on active settings”.

Upload a photo

Endpoint to upload a single image into the system:

@app.post("/upload/")
async def upload_photo(file: UploadFile = File(...), db: Session = Depends(get_db)):
    # Save the original photo
    file_location = f"photos/{file.filename}"
    with open(file_location, "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)

    # Create and save the thumbnail
    image = Image.open(file_location)
    image.thumbnail((128, 128))  # Creation of the thumbnail with dimensions of 128x128
    thumbnail_location = f"photos/thumbnails/{file.filename}"
    image.save(thumbnail_location)

    # Save the information in the database for the photo and thumbnail
    new_photo = Photo(filename=file.filename, filepath=file_location, thumbnail_path=thumbnail_location)
    db.add(new_photo)
    db.commit()
    db.close()

    # Send notification
    send_pushover_notification(
        title="New photo uploaded",
        message=f"The photo {file.filename} has been uploaded successfully.",
        image_path=thumbnail_location
    )

    return {"info": f"File '{file.filename}' and thumbnail uploaded successfully!"}

Saves the photo in the photos/ folder.

Generates a 128×128 thumbnail and saves it to photos/thumbnails/.

Registers the file in the database with its path.

Sends a Pushover notification informing the user of the upload.

Returns “File uploaded successfully”.

Multiple photo upload

This endpoint allows you to upload multiple images in a single request:

@app.post("/upload_batch/")
async def upload_photos_batch(files: List[UploadFile] = File(...), db: Session = Depends(get_db)):
    for file in files:
        # Saving the photo
        file_location = f"photos/{file.filename}"
        with open(file_location, "wb") as buffer:
            shutil.copyfileobj(file.file, buffer)

        # Creating the thumbnail
        thumbnail_location = f"photos/thumbnails/{file.filename}"
        image = Image.open(file_location)
        image.thumbnail((128, 128))
        image.save(thumbnail_location)

        # Insertion into the database
        new_photo = Photo(filename=file.filename, filepath=file_location, thumbnail_path=thumbnail_location)
        db.add(new_photo)
    
    db.commit()  # Commit to save all photos in one go
    return {"info": f"Batch of {len(files)} photos uploaded successfully!"}

Iterates over the received files and saves them to photos/.

Generates thumbnails for each image and saves them in photos/thumbnails/.

Records every image in the database.

Performs a single commit, reducing the number of writes to the database.

Returns “Batch of X photos uploaded successfully!”.

Retrieving photo list with pagination

This endpoint allows you to get a paginated list of archived images:

@app.get("/photos/")
def list_photos(page: int = 1, page_size: int = 10, db: Session = Depends(get_db)):
    all_photos = db.query(Photo).order_by(Photo.id.desc()).all()  # We retrieve all photos sorted by descendant ID
    total_photos = len(all_photos)  # Let's count the total number of photos

    start = (page - 1) * page_size  # Let's calculate the beginning
    end = start + page_size  # Let's calculate the end

    # If start is out of range of photos, we return an empty list
    if start >= total_photos:
        return []

    # Let's take the sublist between start and end
    paginated_photos = all_photos[start:end]
    paginated_photos_data = [{"id": photo.id, "filename": photo.filename, "thumbnail": photo.thumbnail_path} for photo in paginated_photos]

    db.close()
    return paginated_photos_data

Sorts photos in descending order by ID, with the most recent ones shown first.

Applies pagination:

  • page: number of the requested page.
  • page_size: number of images per page.

Calculates indices to select only the necessary images.

If the requested page is beyond the total number of images, returns an empty list.

Closes the database connection to optimize resource usage.

Calculating the total number of photos

Endpoint that returns the total number of images and pages available:

@app.get("/photos/count/")
def get_photo_count(page_size: int = 10, db: Session = Depends(get_db)):
    total_photos = db.query(Photo).count()  # Count all photos in database
    total_pages = (total_photos + page_size - 1) // page_size  # Calculate the number of pages

    return {
        "total_photos": total_photos,
        "total_pages": total_pages,
        "page_size": page_size
    }

Counts the total number of photos stored in the database.

Calculates the number of pages based on the requested page size.

Returns data in JSON format, including:

  • total_photos: total number of images stored.
  • total_pages: total number of pages available.
  • page_size: number of images per page.

Search for photos by name

Allows you to search for specific images by filtering by name:

@app.get("/photos/search")
def search_photos(filename: str = None, db: Session = Depends(get_db)):
    query = db.query(Photo)
    
    if filename:
        query = query.filter(Photo.filename.contains(filename))
    
    photos = query.all()
    db.close()

    return [{"id": photo.id, "filename": photo.filename} for photo in photos]

Search for images by file name.

If a filename parameter is passed, performs a filter on the database.

Returns results with file ID and file name.

Closes the database connection to save resources.

Recovering metadata from a photo

Allows you to obtain detailed information about a specific image:

@app.get("/photos/{photo_id}")
def get_photo_metadata(photo_id: int, db: Session = Depends(get_db)):
    photo = db.query(Photo).filter(Photo.id == photo_id).first()
    if not photo:
        raise HTTPException(status_code=404, detail="Photo not found")
    return {"id": photo.id, "filename": photo.filename, "filepath": photo.filepath}

Searches for the image in the database using the ID.

If it does not exist, returns 404 – Photo not found.

If found, returns:

  • ID
  • File name
  • File path.

Downloading a photo

Allows you to download a photo with the original name:

@app.get("/download/{photo_id}")
def download_photo(photo_id: int, db: Session = Depends(get_db)):
    photo = db.query(Photo).filter(Photo.id == photo_id).first()
    if not photo:
        raise HTTPException(status_code=404, detail="Photo not found")

    return FileResponse(photo.filepath, media_type="image/jpeg", filename=photo.filename)

Checks if the photo exists in the database.

If it does not exist, returns 404 – Photo not found.

If it exists, returns the file with its original name.

Downloading a photo thumbnail

Allows you to download a smaller version of an image:

@app.get("/download-thumbnail/{photo_id}")
def download_thumbnail(photo_id: int, db: Session = Depends(get_db)):
    photo = db.query(Photo).filter(Photo.id == photo_id).first()
    if not photo:
        raise HTTPException(status_code=404, detail="Photo not found")

    thumbnail_path = os.path.join("photos/thumbnails", photo.filename)
    if os.path.exists(thumbnail_path):
        return FileResponse(thumbnail_path, media_type="image/jpeg", filename=f"thumb-{photo.filename}")
    else:
        raise HTTPException(status_code=404, detail="Thumbnail not found")

Searches for the photo in the database using the ID.

If it does not exist, returns 404 – Photo not found.Verifica se il file della miniatura esiste.

If it does not exist, returns 404 – Thumbnail not found.

If the thumbnail exists, return it as a downloadable file.

Deleting a photo

Allows you to delete a photo and its thumbnail from the system:

@app.delete("/photos/{photo_id}")
def delete_photo(photo_id: int, db: Session = Depends(get_db)):
    # Find the photo you want to delete
    photo = db.query(Photo).filter(Photo.id == photo_id).first()

    if not photo:
        db.close()
        return {"error": "Photo not found"}

    # We delete the original image file if it exists
    if os.path.exists(photo.filepath):
        os.remove(photo.filepath)

    # We delete the thumbnail if it exists
    if os.path.exists(photo.thumbnail_path):
        os.remove(photo.thumbnail_path)

    # We delete the entry from the database
    db.delete(photo)
    db.commit()
    db.close()

    return {"info": f"Photo '{photo.filename}' and thumbnail successfully deleted!"}

Check if the photo exists in the database.

If it does not exist, returns “Photo not found”.

Deletes the original file from the filesystem.

Deletes the corresponding thumbnail.

Deletes the record from the database.

Returns “Photo deleted successfully”.

Upload a video

This endpoint allows you to upload a video to the system, generate a thumbnail and save it to the database:

@app.post("/upload_video/")
async def upload_video(file: UploadFile = File(...), db: Session = Depends(get_db)):
    video_location = f"videos/{file.filename}"
    motion_video_location = f"videos/motion/{file.filename}"
    thumbnail_location = f"videos/thumbnails/{file.filename}.jpg"

    try:
        # 1. Save the original video
        with open(video_location, "wb") as buffer:
            shutil.copyfileobj(file.file, buffer)
        print(f"[UPLOAD] Video saved: {video_location}")

        # Set read/write permissions for everyone (optional)
        os.chmod(video_location, 0o666)  # Permissions: rw-rw-rw-

        # 2. Check the video
        try:
            with VideoFileClip(video_location) as clip:
                duration = clip.duration
                if duration <= 0:
                    print(f"[VALIDATION] Invalid duration for video: {video_location}")
                    raise HTTPException(status_code=400, detail="Invalid video or invalid duration")
                print(f"[VALIDATION] Valid video with duration: {duration} seconds")
        except Exception as e:
            print(f"[VALIDATION] Error during video validation: {e}")
            raise HTTPException(status_code=400, detail="Invalid or corrupt video")

        # 3. Thumbnail creation
        try:
            with VideoFileClip(video_location) as clip:
                frame = clip.get_frame(1)  # It takes the frame at the first second
                from PIL import Image
                image = Image.fromarray(frame)
                image.save(thumbnail_location)
                print(f"[THUMBNAIL] Successfully created: {thumbnail_location}")
        except Exception as e:
            print(f"[THUMBNAIL] Error creating thumbnail: {e}")
            raise HTTPException(status_code=500, detail="Error creating thumbnail")

        # 4. Save to database
        try:
            existing_video = db.query(Video).filter(Video.filename == file.filename).first()
            if existing_video:
                print(f"[DATABASE] Video already present: {file.filename}")
                raise HTTPException(status_code=400, detail="Video already present in the database")

            new_video = Video(
                filename=file.filename,
                filepath=video_location,
                thumbnail_path=thumbnail_location,
                duration=duration
            )
            db.add(new_video)
            db.commit()
            print(f"[DATABASE] Video saved in database: {file.filename}")
        except Exception as e:
            print(f"[DATABASE] Error saving to database: {e}")
            raise HTTPException(status_code=500, detail="Error saving to database")

        # 5. Send notification
        try:
            notify_response = requests.post(
                "http://localhost:8000/notify",
                json={
                    "title": "New video uploaded",
                    "message": f"The video {file.filename} has been uploaded successfully.",
                    "image_path": thumbnail_location
                },
                timeout=30
            )
            if notify_response.status_code != 200:
                print(f"[NOTIFICATION] Error: {notify_response.status_code} - {notify_response.text}")
                #raise HTTPException(status_code=500, detail="Error sending notification")
            print("[NOTIFICATION] Sent successfully.")
        except requests.Timeout:
            print("[NOTIFICATION] Timeout while sending.")
            #raise HTTPException(status_code=500, detail="Timeout while sending notification")
        except Exception as e:
            print(f"[NOTIFICATION] Error: {e}")
            #raise HTTPException(status_code=500, detail=f"Error sending notification: {e}")



    except Exception as e:
        print(f"[ERROR] {e}")


    # Delete the video from the videos/motion folder
    print(f"[CLEANUP] {motion_video_location}")
    if os.path.exists(motion_video_location):
        print(f"[DEBUG] Found video to delete: {motion_video_location}")
        os.remove(motion_video_location)
        print(f"[DEBUG] Video successfully deleted: {motion_video_location}")
    else:
        print(f"[DEBUG] File not found: {motion_video_location}")

Saves the video file to videos/.

Checks the validity of the video (duration greater than 0 seconds).

Creates a thumbnail by capturing a frame from the first second.

Saves the video in the database, avoiding duplicates.

Sends a notification to the user.

Deletes the temporary file from the videos/motion/ folder.

Retrieving video list with pagination

Allows you to get a list of archived videos, with pagination support:

@app.get("/videos/")
def list_videos(page: int = 1, page_size: int = 10, db: Session = Depends(get_db)):
    # Get all videos from database, sorted by descendant ID
    all_videos = db.query(Video).order_by(Video.id.desc()).all()

    # Total videos
    total_videos = len(all_videos)

    # Calculation of indexes for pagination
    start = (page - 1) * page_size
    end = start + page_size

    # Check for out-of-range indices
    if start >= total_videos:
        print("Indices out of range: no video available for this page.")
        return []

    # Retrieving videos for the current page
    paginated_videos = all_videos[start:end]
    paginated_videos_data = [{"id": video.id, "filename": video.filename, "thumbnail": video.thumbnail_path} for video in paginated_videos]

    return paginated_videos_data

Sorts videos in descending order by ID.

Applies pagination, showing only a certain number of videos per page.

Returns a JSON list with:

  • ID
  • File name
  • Thumbnail path.

Retrieve total number of videos

Returns the total number of videos saved in the system:

@app.get("/videos/count/")
def get_video_count(page_size: int = 10, db: Session = Depends(get_db)):
    total_videos = db.query(Video).count()
    total_pages = (total_videos + page_size - 1) // page_size

    return {
        "total_videos": total_videos,
        "total_pages": total_pages,
        "page_size": page_size
    }

Counts the total number of videos in the database.

Calculates the number of pages based on the requested page size.

Returns information in JSON.

Downloading a video thumbnail

Allows you to download a video thumbnail:

@app.get("/videos/thumbnails/{filename}")
def get_video_thumbnail(filename: str):
    thumbnail_path = os.path.join(VIDEO_THUMBNAIL_DIRECTORY, filename)
    if os.path.exists(thumbnail_path):
        return FileResponse(thumbnail_path, media_type="image/jpeg")
    else:
        raise HTTPException(status_code=404, detail="Thumbnail not found")

Check if the thumbnail exists.

If the file is present, it returns it as an image.

If the file does not exist, returns 404 – Thumbnail not found.

Downloading a video

Allows you to download a video from the system:

@app.get("/videos/download/{video_id}")
def serve_video_by_id(video_id: int, db: Session = Depends(get_db)):
    video = db.query(Video).filter(Video.id == video_id).first()
    if not video:
        raise HTTPException(status_code=404, detail="Video not found")
    
    video_path = video.filepath
    print(f"[DEBUG] Video required for ID {video_id}: {video_path}")
    if os.path.exists(video_path):
        return FileResponse(video_path, media_type="video/mp4")
    else:
        raise HTTPException(status_code=404, detail="Video not found")

Retrieves video from database using ID.

If the file exists, it returns it as an MP4 file.

If the file does not exist, returns 404 – Video not found.

Restarting the service via Flask API

This endpoint sends an HTTP request to a Flask API to restart the service:

@app.post("/restart-service")
def restart_service_via_flask():
    flask_api_url = "http://192.168.1.190:5000/restart-service"  # Flask API URL
    try:
        response = requests.post(flask_api_url, timeout=10)  # 10 second safety timeout
        if response.status_code == 200:
            return {"info": "Service restart completed via Flask API."}
        else:
            return {"error": f"Error while restarting: {response.status_code} - {response.text}"}
    except requests.RequestException as e:
        return {"error": f"Error communicating with the Flask API: {str(e)}"}

Sends a POST request to the Flask API on the local network (192.168.1.190:5000).

Sets a timeout of 10 seconds to avoid crashes.

If the service responds with 200 OK, confirms that the reboot is complete.

If the request fails, it returns an error message.

Sending email notifications (SMTP)

This feature sends email notifications via an SMTP server (e.g. Gmail):

def send_mail_notification(title, message, image_path=None):
    if config.get('notification', 'gmail_enabled').lower() != 'true':
        print("Email notification disabled.")
        return

    # SMTP configurations
    smtp_server = config.get('notification', 'smtp_server')
    smtp_port = config.get('notification', 'smtp_port')
    sender_email = config.get('notification', 'sender_email')
    app_password = config.get('notification', 'app_password')
    receiver_email = config.get('notification', 'receiver_email')

    # Composing the email
    msg = MIMEMultipart()
    msg['From'] = sender_email
    msg['To'] = receiver_email
    msg['Subject'] = title

    # Body of the message
    msg.attach(MIMEText(message, 'plain'))

    # Attach image, if present
    if image_path:
        with open(image_path, 'rb') as img:
            img_data = img.read()
            image = MIMEImage(img_data, name="notification_image.jpg")
            msg.attach(image)

    # Sending email via Gmail SMTP
    try:
        with smtplib.SMTP(smtp_server, smtp_port) as server:
            server.starttls()
            server.login(sender_email, app_password)
            server.sendmail(sender_email, receiver_email, msg.as_string())
        print("Email sent successfully with Gmail.")
    except Exception as e:
        print(f"Error sending email with Gmail: {e}")

Reads SMTP configuration to get email server details.

Creates an email with a title and message body.

Attaches an image (if present), useful for preview notifications.

Sends the message via SMTP.

If sending fails, prints an error in the logs.

Sending Pushover Notifications

Pushover is a notification service for mobile and desktop:

def send_pushover_notification(title, message, image_path=None):

    # Checks if Pushover is enabled
    if config.get('notification', 'pushover_enabled').lower() != 'true':
        print("Pushover notification disabled.")
        return

    # Your Pushover API credentials
    api_token = config.get('notification', 'pushover_token')   # The application token
    user_key = config.get('notification', 'pushover_user_key')  # Your user key

    # Basic notification parameters
    data = {
        "token": api_token,
        "user": user_key,
        "title": title,
        "message": message,
    }

    # Check if there is an image to attach (e.g. the thumbnail)
    if image_path:
        with open(image_path, "rb") as image_file:
            response = requests.post("https://api.pushover.net/1/messages.json", data=data, files={"attachment": image_file})
    else:
        response = requests.post("https://api.pushover.net/1/messages.json", data=data)

    # Check the response from the API
    if response.status_code == 200:
        print("Pushover notification sent successfully")
    else:
        print(f"Error sending pushover notification: {response.status_code} - {response.text}")

Checks if Pushover notifications are enabled.

Uploads your API credentials (token and user key).

Sends an HTTP request to Pushover, with or without an image attached.

Verifies the API response, printing errors if there are problems.

Sending notifications via Telegram

Telegram allows you to send messages and images to registered bots:

def send_telegram_notification(title, message, image_path=None):
    # Check if Telegram is enabled
    if config.get('notification', 'telegram_enabled').lower() != 'true':
        print("Telegram notification disabled.")
        return

    # Retrieve the parameters from the configuration file
    token = config.get('notification', 'telegram_token')
    chat_id = config.get('notification', 'chatid_telegram')
    telegram_api_url = f"https://api.telegram.org/bot{token}/sendMessage"

    # Configure the message
    data = {
        "chat_id": chat_id,
        "text": f"{title}\n\n{message}"
    }

    # Send the text message
    response = requests.post(telegram_api_url, data=data)
    if response.status_code == 200:
        print("Telegram notification sent successfully")
    else:
        print(f"Error sending Telegram notification: {response.status_code} - {response.text}")

    # Send image as separate file (if exists)
    if image_path:
        telegram_photo_url = f"https://api.telegram.org/bot{token}/sendPhoto"
        with open(image_path, "rb") as photo:
            response = requests.post(telegram_photo_url, data={"chat_id": chat_id}, files={"photo": photo})
        if response.status_code == 200:
            print("Telegram photo sent successfully")
        else:
            print(f"Error sending Telegram photo: {response.status_code} - {response.text}")

Checks if Telegram notifications are enabled.

Loads token and chat ID from the configuration file.

Sends a text message via Telegram API.

If present, also sends an image as an attachment file.

Tests the API response, printing errors if there are any problems.

The motion_monitor.py script: the multimedia file supervisor

The motion_monitor.py script is responsible for monitoring the folders where files are saved by Motion, the software that manages motion detection. This script is responsible for monitoring the multimedia files (photos and videos) generated by the motion detection software. Its main task is to detect new files, check their stability and upload them via API. It also manages the cleaning of older files to keep storage under control.

Main features

  • Continuous monitoring of photo and video folders: detects new files generated by Motion.
  • Batch processing: to optimize the sending of files to the backend, images and videos are uploaded in groups instead of being sent individually.
  • File validation: checks that videos are complete and free of errors before uploading them to the database.
  • Storage management: automatically deletes the oldest files when the maximum number set in the configuration is exceeded.

Note: this script operates in a continuous loop, governed by configurable parameters in the config.ini file.

Importing libraries

import configparser
import os
import time
import requests
from contextlib import ExitStack
import psutil
from moviepy.editor import VideoFileClip
import shutil
from database import get_db, Photo, Video

configparser: reads the config.ini configuration file.

os, time, shutil: file, directory, and time operations management.

requests: to send HTTP requests (uploads and notifications).

psutil: process control to check if a file is in use.

moviepy.editor.VideoFileClip: video analysis to check duration and stability.

ExitStack: efficient management of opening and closing multiple files.

database (SQLAlchemy): database interaction to manage photos and videos.

Configuration and parameters

MAX_STABILITY_CHECKS = 5  # Maximum number of file stability checks
STABILITY_INTERVAL = 2  # Interval in seconds between each check

MAX_STABILITY_CHECKS: maximum number of retries to check if a file is stable.

STABILITY_INTERVAL: time interval between one check and the next.

Loading the configuration file

config = configparser.ConfigParser()
config.read('config.ini')

Loads the config.ini configuration file to read the system parameters.

INIT_DELAY = config.getint('general', 'init_delay')
PROCESS_INTERVAL = config.getint('general', 'process_interval')
MAX_PHOTOS = config.getint('general', 'max_photos')
MAX_VIDEOS = config.getint('general', 'max_videos')
BATCH_SIZE = config.getint('general', 'batch_size')

INIT_DELAY: initial delay before processing files.

PROCESS_INTERVAL: waiting time between two scans of the folder.

MAX_PHOTOS / MAX_VIDEOS: maximum number of photos and videos stored.

BATCH_SIZE: maximum number of files to upload in a batch.

Folder paths

PHOTOS_DIRECTORY = config.get('paths', 'photo_directory')
VIDEOS_DIRECTORY = config.get('paths', 'video_directory')
PHOTOS_DIR = config.get('paths','photos_dir')
VIDEOS_DIR = config.get('paths', 'videos_dir')
THUMBNAIL_DIRECTORY = config.get('paths','thumbnail_directory')
VIDEO_THUMBNAIL_DIRECTORY = config.get('paths','video_thumbnail_directory')

Directory paths where Motion saves images and videos.

API URLs

BATCH_UPLOAD_API_URL = config.get('paths','batch_upload_api_url')
VIDEO_UPLOAD_API_URL = config.get('paths', 'upload_video_api_url')
NOTIFY_API_URL = config.get('paths','notify_api_url')

API URLs for uploading photos/videos and sending notifications.

Initialization of variables

start_time = time.time()
last_processed_time = time.time()
photo_batch = []

start_time: saves the script startup timestamp.

last_processed_time: keeps track of the last file upload.

photo_batch: list of photos waiting to be uploaded.

Creating folders if they don’t exist

if not os.path.exists(PHOTOS_DIR):
    os.makedirs(PHOTOS_DIR)

if not os.path.exists(VIDEOS_DIR):
    os.makedirs(VIDEOS_DIR)

Checks if the folders for photos and videos exist. If they don’t, creates them.

Handling 0 byte files

zero_byte_count = {}  # Global dictionary to track files to 0B
MAX_ZERO_BYTE_CHECKS = 50  # Maximum threshold for files at 0B

Check if a video stays at 0 bytes for too long. If it stays at 0 bytes for a short time, it means that motion is saving it gradually. If it stays at 0 bytes for a long time, it means that it is corrupt and will be deleted.

Determining the Motion Mode

def get_mode_from_motion_conf(motion_config_path):
    try:
        with open(motion_config_path, 'r') as f:
            lines = f.readlines()
        mode = "unknown"
        for line in lines:
            line = line.strip()
            if line.startswith("output_pictures") and "on" in line:
                mode = "photos"
            elif line.startswith("ffmpeg_output_movies") and "on" in line:
                mode = "videos"
        return mode
    except FileNotFoundError:
        print("The motion.conf file was not found.")
        return "unknown"

Reads the motion.conf configuration file and determines whether Motion is in photo or video mode.

Check if a file is in use

def is_file_in_use(file_path):
    """Check if the file is in use."""
    for proc in psutil.process_iter(['pid', 'name', 'open_files']):
        try:
            open_files = proc.info['open_files']
            if open_files:
                for open_file in open_files:
                    if open_file.path == file_path:
                        print(f"The file {file_path} is still in use by the process {proc.info['name']} (PID: {proc.info['pid']}).")
                        return True
        except psutil.AccessDenied:
            continue
        except psutil.NoSuchProcess:
            continue
    return False

Checks if a file is still in use by another process.

Checks the stability of the videos

def is_video_ready(video_path):
    """
    Check if the video file is stable and valid for uploading.
    """
    try:
        current_size = os.path.getsize(video_path)
        if current_size == 0:
            os.remove(video_path)
            return False

        previous_size = current_size
        for i in range(MAX_STABILITY_CHECKS):
            time.sleep(STABILITY_INTERVAL)
            current_size = os.path.getsize(video_path)
            if current_size == previous_size and not is_file_in_use(video_path):
                break
            previous_size = current_size
        else:
            return False

        with VideoFileClip(video_path) as clip:
            if clip.duration <= 0:
                return False
        return True
    except Exception as e:
        return False

Deletes videos that have been at 0 bytes for too long.

Checks if the file has finished growing before considering it stable.

Checks the video duration to make sure it is not corrupted.

Batch video upload

def upload_videos_batch(video_batch):
    print(f"[BATCH] Starting batch loading of {len(video_batch)} video...")
    for video in video_batch:
        video_path = os.path.join(VIDEOS_DIR, video)
        video_path = os.path.normpath(video_path)
        
        try:
            if not os.path.exists(video_path):
                print(f"[UPLOAD ERROR] Video not found for upload: {video_path}")
                continue
            
            with open(video_path, "rb") as video_file:
                response = requests.post(
                    VIDEO_UPLOAD_API_URL,
                    files={"file": video_file}
                )
            response.raise_for_status()
            print(f"[UPLOAD SUCCESS] Video uploaded: {video_path}")
        except Exception as e:
            print(f"[UPLOAD ERROR] Error loading video {video_path}: {e}")

Loads all videos in the video_batch list.

Checks if the file actually exists before proceeding. If the upload is successful, prints [UPLOAD SUCCESS], otherwise show an error.

Batch upload photos

def upload_photos_batch():
    global photo_batch
    files = []
    with ExitStack() as stack:
        for photo in photo_batch:
            photo_path = os.path.join(PHOTOS_DIR, photo)
            try:
                files.append(("files", (photo, stack.enter_context(open(photo_path, "rb")), "image/jpeg")))
            except FileNotFoundError:
                print(f"Photo not found for batch upload: {photo_path}")
        
        if files:
            response = requests.post(BATCH_UPLOAD_API_URL, files=files)
            if response.status_code == 200:
                print(f"Batch of {len(photo_batch)} photos uploaded successfully")
                
                first_photo = photo_batch[0]
                notify_response = requests.post(NOTIFY_API_URL, json={
                    "title": "New batch of photos uploaded",
                    "message": f"{len(photo_batch)} new photos uploaded successfully.",
                    "image_path": f"photos/thumbnails/{first_photo}"
                })

                for photo in photo_batch:
                    photo_path = os.path.join(PHOTOS_DIR, photo)
                    if os.path.exists(photo_path):
                        os.remove(photo_path)
                        print(f"Temporary photo removed: {photo_path}")
            else:
                print(f"Batch upload error: {response.status_code} - {response.text}")

    photo_batch.clear()

Prepares photos for batch upload.

Sends an HTTP request to the BATCH_UPLOAD_API_URL API.

If the upload is successful, it sends a notification with the thumbnail of the first photo.

Deletes uploaded photos from the folder.

File monitoring

The heart of the script is the monitor_files() function, which continuously monitors the Motion folder, processes new files, and cleans out old ones. Since this is a very long and complex function, we will examine it in pieces.

def monitor_files():
    global last_processed_time
    while True:
        mode = get_mode_from_motion_conf('/etc/motion/motion.conf')

Determines whether Motion is taking photos or recording videos.

Photo management

        if mode == "photos":
            print("Photo mode active. Scanning the photo folder...")
            photos = sorted(os.listdir(PHOTOS_DIR))

            for photo in photos:
                photo_path = os.path.join(PHOTOS_DIR, photo)
                current_time = time.time()
                if current_time - start_time < INIT_DELAY:
                    os.remove(photo_path)
                    print(f"Photo skipped during startup: {photo_path}")
                    continue

                photo_batch.append(photo)
                print(f"Adding photo to batch: {photo}")

                if len(photo_batch) >= BATCH_SIZE:
                    print("Batch threshold reached, loading...")
                    upload_photos_batch()
                    last_processed_time = time.time()

Checks the photo folder and sorts the files. If the script has just been started, it happens that Motion starts taking bursts of photos because it is adapting to the environmental conditions that are not yet stable for it, so the script takes care of deleting the photos taken in a certain interval of time from its start (avoids startup errors).

Adds photos to the upload queue. If the queue reaches BATCH_SIZE, uploads the photos.

Automatic photo cleaning

            photos_to_be_deleted = sorted(
                [photo for photo in os.listdir(PHOTOS_DIRECTORY) if photo not in ("motion", "thumbnails")],
                key=lambda f: os.path.getmtime(os.path.join(PHOTOS_DIRECTORY, f))
            )
            if len(photos_to_be_deleted) > MAX_PHOTOS and not photo_batch:
                print(f"[CLEANUP] Photo number: {len(photos_to_be_deleted)}")
                print(f"[CLEANUP] Maximum number of photos: {MAX_PHOTOS}")
                db = next(get_db())  
                try:
                    for old_photo in photos_to_be_deleted[:len(photos_to_be_deleted) - MAX_PHOTOS]:
                        photo_path = os.path.join(PHOTOS_DIRECTORY, old_photo)

                        if os.path.exists(photo_path):
                            os.remove(photo_path)
                            print(f"[CLEANUP] Old photo removed: {photo_path}")
                        
                        thumbnail_path = os.path.join(THUMBNAIL_DIRECTORY, old_photo)
                        if os.path.exists(thumbnail_path):
                            os.remove(thumbnail_path)
                            print(f"[CLEANUP] Photo thumbnail removed: {thumbnail_path}")
                        
                        photo_entry = db.query(Photo).filter(Photo.filename == old_photo).first()
                        if photo_entry:
                            db.delete(photo_entry)
                            db.commit()
                            print(f"[CLEANUP] Removed database entry for photo: {old_photo}")
                finally:
                    db.close()

Checks if there are more photos than MAX_PHOTOS.

Sorts photos from oldest to newest.

Deletes the oldest ones from both the filesystem and the database.

Video Management

        elif mode == "videos":
            print("Video mode active. Scanning video folder...")
            videos = sorted(os.listdir(VIDEOS_DIR))

            processed_files = set()
            video_batch = []

            for video in videos:
                video_path = os.path.join(VIDEOS_DIR, video)
                current_time = time.time()

                if video in processed_files:
                    continue

                if not is_video_ready(video_path):
                    print(f"[SKIP] Video not ready: {video_path}")
                    continue

                video_batch.append(video)
                processed_files.add(video)
                print(f"Added video to batch: {video}")

                if len(video_batch) >= BATCH_SIZE:
                    print("Batch threshold reached, loading...")
                    upload_videos_batch(video_batch)
                    video_batch = []

Sorts videos by creation date.

Checks if the video is stable with is_video_ready().

Uploads videos in batch when it reaches BATCH_SIZE.

Automatic video cleanup

            all_videos = sorted(
                [f for f in os.listdir(VIDEOS_DIRECTORY) if f not in {"thumbnails", "motion"}],
                key=lambda f: os.path.getmtime(os.path.join(VIDEOS_DIRECTORY, f)) 
            )

            if len(all_videos) > MAX_VIDEOS:
                print(f"[CLEANUP] Video number: {len(all_videos)}")
                print(f"[CLEANUP] Maximum number of videos: {MAX_VIDEOS}")
                
                db = next(get_db())  
                try:
                    old_videos = all_videos[:len(all_videos) - MAX_VIDEOS]
                    for old_video in old_videos:
                        old_video_path = os.path.join(VIDEOS_DIRECTORY, old_video)

                        if os.path.exists(old_video_path):
                            os.remove(old_video_path)
                            print(f"[CLEANUP] Old video removed: {old_video_path}")

                        old_thumbnail_path = os.path.join(VIDEO_THUMBNAIL_DIRECTORY, f"{old_video}.jpg")
                        if os.path.exists(old_thumbnail_path):
                            os.remove(old_thumbnail_path)
                            print(f"[CLEANUP] Video thumbnail removed: {old_thumbnail_path}")

                        video_entry = db.query(Video).filter(Video.filename == old_video).first()
                        if video_entry:
                            db.delete(video_entry)
                            db.commit()
                            print(f"[CLEANUP] Removed database entry for video: {old_video}")
                finally:
                    db.close()

Deletes older videos when MAX_VIDEOS is exceeded.

Also cleans records from database.

The database.py script: database management

This script provides the functionality to connect to and manage the SQLite database, which stores metadata about photos, videos, and users. The database.py script uses an ORM (Object-Relational Mapping) to manage the interaction with the SQLite database. An ORM is a programming technique that allows you to manipulate a relational database using objects and methods of a programming language, rather than writing SQL queries directly. This approach simplifies development, making the code more readable and maintainable, and provides a layer of abstraction that makes it easy to switch between different database types without changing the application code.

Main features

  • Data model definition: uses SQLAlchemy to manage the database structure.
  • Backend interface: provides functions to create, read, update, and delete records in the database.
  • Auto-connect: manages the database connection avoiding concurrency issues between API requests.

Note: this script is used by main.py and motion_monitor.py for all data operations.

The database.py file defines the video surveillance application database using SQLAlchemy, which is an ORM (Object-Relational Mapper) to interact with the SQLite database without directly writing SQL queries and performs the following functionality:

Database connection

DATABASE_URL = "sqlite:///./data/videosurveillance_db.sqlite"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

The database is a SQLite file located at ./data/videosurveillance_db.sqlite.

engine is the object that allows the connection to the database.

SessionLocal is a session manager that allows you to perform operations on the database without closing and reopening the connection each time.

Base is the base class from which all table models derive.

Definition of tables

Three tables are defined: Photo, Video and User, each with its own fields.

Photo table

class Photo(Base):
    __tablename__ = "photos"
    id = Column(Integer, primary_key=True, index=True)
    filename = Column(String, unique=True, index=True)
    filepath = Column(String)
    thumbnail_path = Column(String) 

Contains information about photos saved by the system. Each photo has:

  • id: unique numeric identifier.
  • filename: file name.
  • filepath: path to the original file.
  • thumbnail_path: thumbnail path.

Video Table

class Video(Base):
    __tablename__ = "videos"
    id = Column(Integer, primary_key=True, index=True)
    filename = Column(String, unique=True, index=True)
    filepath = Column(String)
    thumbnail_path = Column(String)  # Thumbnail generated from video
    duration = Column(String)        # Video length, optional

Contains information about recorded videos. Each video has:

  • id: unique numeric identifier.
  • filename: file name.
  • filepath: path to the original file.
  • thumbnail_path: path of the thumbnail generated by the video.
  • duration: video duration (string, can be in hh:mm:ss format).

User table

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    password = Column(String)  # Hashed password
    is_admin = Column(Boolean, default=False)  # Flag to distinguish admin and normal users

Contains the users registered in the system. Each user has:

  • id: unique numeric identifier.
  • username: username.
  • password: password saved in hashed format (for security).
  • is_admin: boolean flag to indicate whether the user is an administrator.

Creating tables in the database

Base.metadata.create_all(bind=engine)

Checks whether the tables exist in the database and automatically creates them if not.

Function to get a database session

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

This function generates a database session that can be used in code to execute queries. When the session is no longer needed, it is automatically closed.

The host_service.py script: the service restart handler

The host_service.py script is a small Flask server that provides an API to restart the videosurveillance.service service when requested from the web interface.

Main features

  • Starting a Flask server: the script starts a small web server that exposes a REST API.
  • Handling service restart: when the restart button is pressed on the configuration page, an HTTP request is sent to this script.
  • Running system commands: upon receiving the request, the script runs the command:
sudo systemctl restart videosurveillance.service

to restart the backend and frontend services without having to manually log into the terminal.

Note: this script is started as a system service (host_service.service) to always run in the background.

The host_service.py file is a Flask microservice that allows you to restart the video surveillance service (videosurveillance.service) via an HTTP request. This allows the user to reboot the system directly from the web interface, without having to manually access the Raspberry Pi terminal. The script performs the following functions:

Importing libraries

from flask import Flask, jsonify
import subprocess

Flask is used to create a minimal HTTP API.

jsonify is used to return responses in JSON format.

subprocess allows you to execute system commands directly from Python.

Creating the Flask App

app = Flask(__name__)

Creates a Flask app instance that will handle HTTP requests.

Defining the endpoint for service restart

@app.route('/restart-service', methods=['POST'])
def restart_service():
    try:
        subprocess.run(["sudo", "systemctl", "restart", "videosurveillance.service"], check=True)
        return jsonify({"status": "success", "message": "Service restarted successfully"}), 200
    except subprocess.CalledProcessError as e:
        return jsonify({"status": "error", "message": f"Failed to restart service: {e}"}), 500

A POST endpoint is created that can be reached at the URL /restart-service.

When called, it executes the command:

sudo systemctl restart videosurveillance.service

to restart the video surveillance service.

If the restart is successful, it returns a JSON response with code 200:

{"status": "success", "message": "Service restarted successfully"}

If the command fails, it returns a JSON response with code 500 with the error message.

Starting the Flask server

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)  # Can use any available port

The Flask server starts listening on all network interfaces (0.0.0.0) and on port 5000.

This allows the frontend to send an HTTP request to the microservice to restart videosurveillance.service.

This script is an example of how a Flask microservice can be used to interact with the operating system and improve application management.

The web interface: the meeting point between user and system

To interact with the video surveillance system, I developed a modern and intuitive web interface based on React and using Vite as a build tool, it communicates with the FastAPI backend via REST API. This interface provides easy access to all the system features, such as viewing images and videos, managing configurations, and monitoring the system status in real time. From a technical point of view, the interface is composed of several pages and components that work together to provide a smooth and user-friendly experience. Each component has been designed for a specific purpose. In this section, we will explore the code that governs the main pages and components of the interface, highlighting how they communicate with the backend to provide updated data and manage the operations requested by the user.

The files are mainly distributed between the frontend/src and frontend/src/components/ folders.

Main files in frontend/src

  1. index.html
    • This file is the entry point of your React application. Contains the root container (<div id=”root”></div>) where React assembles the UI.
    • Includes basic page settings, such as favicon, charset, and viewport.
  2. main.jsx
    • This file is the entry point of React.
    • Imports React and ReactDOM, mounts <App /> component inside element with id root in index.html.
    • Uses StrictMode, which helps spot potential problems in your code.
  3. App.jsx
    • It is the core component of the app, which manages the routing and interface structure.
    • Uses React Router (react-router-dom) to define routes for main pages.
    • Imports and integrate different components like LoginPage, AdminDashboard, ThumbnailGrid, VideoGrid, etc.
  4. config.js
    • Contains the global configuration of the application.
    • Defines the API_BASE_URL, which is used by all components to communicate with the backend.

Components in frontend/src/components/

This folder contains the main components used by the application.

  1. ThumbnailGrid.jsx
    • Manages the display of images in grid form.
    • Communicates with the backend to get available photos.
    • Supports pagination and language switching.
    • Use a popup system to view images full screen.
  2. VideoGrid.jsx
    • Similar to ThumbnailGrid, but for video management.
    • Shows video thumbnails, which when clicked open a popup with the playable video.
    • Supports pagination and language switching.
  3. LoginPage.jsx
    • Manages user login.
    • Sends credentials to the backend and receives an authentication token.
    • Saves the token in localStorage for future sessions.
  4. AdminDashboard.jsx
    • Interface for administrators.
    • Displays statistics and advanced controls, such as user management and configuration.
  5. UserSettings.jsx
    • Page to change the password of the logged in user.
    • Sends the request to the backend to update the password.
    • Shows error or success messages.
  6. StatisticsPage.jsx
    • Shows charts and statistics about system usage.
    • Uses Chart.js to visualize data.

The Config Manager

The Config Manager is a web user interface located at videosurveillance/static/config_manager.html.
Its main purpose is to allow the administrator to modify and manage the system configuration parameters, such as security settings, notifications and file paths.

Main features

Loading and displaying configuration: reads the config.ini file from the backend and displays the current values.
Changing parameters: the administrator can update settings such as max_photos, max_videos, notification tokens, and more.
Sending changes to the backend: once the parameters are updated, the Config Manager sends an API request to save the new values ​​to the configuration file.
Validating data: verifies that the entered values ​​are correct before sending them.


Configuration page
Configuration page

File structure

The config_manager.html file consists of:

  • HTML: user interface structure with input fields and buttons.
  • JavaScript: handles loading, editing, and sending configurations.

Let’s now look in detail at the structure of the various files.

File: index.html – React Application Entry Point

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Videosurveillance App</title>
    
    <link rel="icon" type="image/png" href="favicon.png">
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div id="root"></div>
    
    <script type="module" src="/src/main.jsx"></script>
</body>
</html>

The index.html file is the main entry point of your React application.
Defines the basic structure of the HTML page that hosts the video surveillance frontend.

  • Document type declaration (<!DOCTYPE html>) → specifies that the file is an HTML5 document.
  • <html lang=”en”> → sets the primary language of the page to English, useful for SEO and accessibility.
  • <meta charset=”UTF-8″> → ensures compatibility with all international special characters.
  • <meta name=”viewport” content=”width=device-width, initial-scale=1.0″> → makes the layout responsive on mobile devices.
  • <title> → defines the title of the browser tab.
  • <link rel=”icon” href=”favicon.png”> → sets the browser tab icon.
  • <div id=”root”></div>The main element of the React app, where the interface will be mounted.
  • <script type=”module” src=”/src/main.jsx”></script>imports and starts React through the main.jsx file.

File: main.jsx – Initializing the React App

import React from "react";  
import ReactDOM from "react-dom";  
import App from "./App";  

ReactDOM.render(<App />, document.getElementById("root"));  

The main.jsx file is responsible for mounting the React app inside the HTML page and starting the rendering of the UI.

  • Import React → loads the React library, which is needed to manage components.
  • Import ReactDOM → loads ReactDOM, the library that allows you to render your app in the DOM.
  • Import the main component → imports App, which is the entry point for your application.
  • Mounting the application → uses ReactDOM.render() to mount the App component inside the element with id “root” defined in index.html.

File: App.jsx – Main Structure of React App

import React, { useState, useEffect } from "react";
import { BrowserRouter as Router, Route, Routes, Link, Navigate } from "react-router-dom";
import ThumbnailGrid from "./components/ThumbnailGrid";
import VideoGrid from "./components/VideoGrid";
import LoginPage from "./components/LoginPage";
import AdminDashboard from "./components/AdminDashboard";
import UserSettings from "./components/UserSettings";
import StatisticsPage from "./components/StatisticsPage";
import config from './config';

import "./styles/App.css";

The App.jsx file is the entry point of your React application and handles the main interface, routing, and authentication system.

  • Imports React and Hooks → loads React, along with the useState and useEffect hooks, used for managing state and side effects.
  • Imports React Router → it includes routing functions, such as Router, Route, Routes, Link, Navigate, which are necessary for navigating between pages.
  • Imports Components → loads the main interface components: ThumbnailGrid, VideoGrid, LoginPage, AdminDashboard, UserSettings, StatisticsPage.
  • Imports Configuration → imports the config.js file, which contains the API configuration.
  • Imports Stylesheets → loads App.css for interface styling.

State management and authentication

const [isAuthenticated, setIsAuthenticated] = useState(false); 
const [isAdmin, setIsAdmin] = useState(false);

isAuthenticated → keeps track of the user’s authentication status.

isAdmin → indicates whether the authenticated user is an administrator.

Authentication token check

useEffect(() => {
  const token = localStorage.getItem("token");
  if (token) {
    try {
      const decodedToken = JSON.parse(atob(token.split(".")[1])); 
      const isTokenExpired = decodedToken.exp * 1000 < Date.now();
      if (!isTokenExpired) {
        setIsAuthenticated(true);
        setIsAdmin(decodedToken.is_admin || false);
      } else {
        console.warn("Token expired. Removing token...");
        localStorage.removeItem("token");
      }
    } catch (err) {
      console.error("Invalid token:", err);
      localStorage.removeItem("token");
    }
  }
}, []);

Reads the token stored in localStorage.

Decodes the token payload to check the expiration date.

If the token is valid, sets the authentication state and checks if the user is an admin.

If the token is expired or invalid, deletes it from localStorage.

Login management

const handleLogin = (token) => {
  setIsAuthenticated(true);
  localStorage.setItem("token", token);
  try {
    const decodedToken = JSON.parse(atob(token.split(".")[1]));
    setIsAdmin(decodedToken.is_admin || false);
  } catch (error) {
    console.error("Token decoding error:", error);
    setIsAdmin(false);
  }
};

When the user logs in, save the token and update the state.

Decodes the token to verify if the user is admin.

Logout management

const handleLogout = () => {
  setIsAuthenticated(false);
  setIsAdmin(false);
  localStorage.removeItem("token");
  window.location.href = "/login";
};

Removes the token, resets the state and redirects to the login page.

Navbar and navigation

{isAuthenticated && (
  <nav className="navbar">
    <ul>
      <li><Link to="/">Photos</Link></li>
      <li><Link to="/videos">Videos</Link></li>
      {!isAdmin && <li><Link to="/user-settings">User Settings</Link></li>}
      {isAdmin && (
        <>
          <li><Link to="/admin-dashboard">Admin Dashboard</Link></li>
          <li><Link to="/configuration">Configuration</Link></li>
        </>
      )}
      <li><Link to="/statistics">Statistics</Link></li>
      {isAdmin && (
        <li>
          <a href={`${config.API_BASE_URL}/download-backup`} download>Download Backup</a>
        </li>
      )}
      <li>
        <button onClick={handleLogout} className="logout-button">Logout</button>
      </li>
    </ul>
  </nav>
)}

The navbar is only shown if the user is logged in.

Shows sections based on the user’s role (regular user or admin).

If the user is admin, the Admin Dashboard, Configuration and Download Backup items appear.

The Logout button allows you to exit the application.

Route Management with React Router

<Routes>
  <Route path="/" element={isAuthenticated ? <ThumbnailGrid /> : <Navigate to="/login" />} />
  <Route path="/videos" element={isAuthenticated ? <VideoGrid /> : <Navigate to="/login" />} />
  {isAdmin && (
    <>
      <Route path="/configuration" element={isAuthenticated ? (
        <iframe src={`${config.API_BASE_URL}/static/config_manager.html`} style={{ width: "100%", height: "80vh", border: "none" }} title="Configuration" />
      ) : (<Navigate to="/login" />)} />
      <Route path="/admin-dashboard" element={isAuthenticated ? <AdminDashboard /> : <Navigate to="/login" />} />
    </>
  )}
  <Route path="/user-settings" element={isAuthenticated ? <UserSettings /> : <Navigate to="/login" />} />
  <Route path="/login" element={isAuthenticated ? <Navigate to="/" /> : <LoginPage onLogin={handleLogin} />} />
  <Route path="/statistics" element={isAuthenticated ? <StatisticsPage /> : <Navigate to="/login" />} />
</Routes>

Pages are accessible only to authenticated users.

If a user is not authenticated, he is redirected to the login page.

The admin can access the dashboard and configuration.

File: config.js – API configuration

const config = {
    API_BASE_URL: "http://192.168.1.190:8000", // URL backend
};

export default config;

The config.js file has a single purpose: to define the base URL of the application backend, thus centralizing the API configuration for easy modification in case the server address changes.

  • API_BASE_URL → contains the IP address and port of the FastAPI backend (in this case, http://192.168.1.190:8000).
  • Default export → allows you to import the configuration into other files in your React application.

Benefits of centralizing configuration

Easy to edit → if your backend IP changes, just update this file without having to edit every single file in your frontend.
Avoid code duplication → any API request can reference config.API_BASE_URL instead of directly writing the URL everywhere.
Increased readability and maintainability → your code is cleaner and more organized.

🔹 Example of use:
In React components, instead of manually writing the URL, you use:

import config from './config';

fetch(`${config.API_BASE_URL}/photos`)
  .then(response => response.json())
  .then(data => console.log(data));

This way, all API calls point to the centralized URL.

Now let’s see the files in the components folder.

AdminDashboard.jsx

The AdminDashboard component provides an interface for managing application users, allowing the administrator to add, edit, and delete users, as well as manage their credentials.

Importing Dependencies

import React, { useState, useEffect } from "react";
import config from '../config';
import "../styles/AdminDashboard.css";

React and the useState and useEffect hooks are imported for state and side-effect management.

config.js is imported to get the backend URL.

AdminDashboard.css contains the component-specific styling.

Declaration of local status

const [users, setUsers] = useState([]);
const [newUser, setNewUser] = useState({ username: "", password: "" });
const [currentAdmin, setCurrentAdmin] = useState({
  currentPassword: "",
  newUsername: "",
  newPassword: "",
});
const [editingUser, setEditingUser] = useState(null);
const [editUser, setEditUser] = useState({ username: "", password: "" });
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [language, setLanguage] = useState("it");

users contains the list of users.

newUser is an object that contains the username and password of the new user.

currentAdmin manages the administrator credentials to update their data.

editingUser contains the ID of the user being edited.

editUser stores the updated information of the user being edited.

error and success are used to display error or success messages.

language manages the interface language (it for Italian, en for English).

Translation management

const t = (key) => translations[language][key];

const translateBackendMessage = (message) => {
  const translated =
    translations[language].backend[message] || message;
  return translated;
};

t() retrieves translations based on the selected language.

translateBackendMessage() translates error messages from the backend.

Retrieving the user list

const fetchUsers = async () => {
  try {
    const response = await fetch(`${config.API_BASE_URL}/users`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    if (!response.ok) {
      throw new Error(t("errorFetch"));
    }
    const data = await response.json();
    setUsers(data);
  } catch (err) {
    setError(translateBackendMessage(err.message) || t("errorFetch"));
  }
};

This function:

  • Makes a GET request to the /users endpoint to get the list of users.
  • If the request fails, it throws a translated error.

The function is called when the component is mounted with useEffect():

useEffect(() => {
  fetchUsers();
}, []);

Adding a new user

const handleAddUser = async (e) => {
  e.preventDefault();
  setError("");
  setSuccess("");

  try {
    const response = await fetch(`${config.API_BASE_URL}/users/add`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify(newUser),
    });
    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(translateBackendMessage(errorData.detail));
    }
    setSuccess(t("successAdd"));
    setNewUser({ username: "", password: "" });
    fetchUsers();
  } catch (err) {
    setError(translateBackendMessage(err.message));
  }
};

Sends a POST request to /users/add.

If successful, updates the user list and displays a confirmation message.

If unsuccessful, displays an error message.

Deleting a user

const handleDeleteUser = async (username) => {
  try {
    const response = await fetch(`${config.API_BASE_URL}/users/delete`, {
      method: "DELETE",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({ username }),
    });
    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(translateBackendMessage(errorData.detail));
    }
    setSuccess(t("successDelete"));
    setUsers((prev) => prev.filter((user) => user.username !== username));
  } catch (err) {
    setError(translateBackendMessage(err.message));
  }
};

Sends a DELETE request to /users/delete to delete a user.

If successful, updates the user list by removing the deleted user.

Editing a user

const handleEditUser = (user) => {
  setEditingUser(user.id);
  setEditUser({ username: user.username, password: "" });
};

Remembers the user when editing.

const handleSaveUser = async (e) => {
  e.preventDefault();
  setError("");
  setSuccess("");

  try {
    const response = await fetch(
      `${config.API_BASE_URL}/users/update/${editingUser}`,
      {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          username: editUser.username,
          password: editUser.password || undefined,
        }),
      }
    );
    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(translateBackendMessage(errorData.detail));
    }
    setSuccess(t("successUpdate"));
    setEditingUser(null);
    fetchUsers();
  } catch (err) {
    setError(translateBackendMessage(err.message));
  }
};

Sends a PUT request to /users/update/{user_id} to update the user’s data.

Updating admin credentials

const handleUpdateAdmin = async (e) => {
  e.preventDefault();
  setError("");
  setSuccess("");

  try {
    const response = await fetch(`${config.API_BASE_URL}/admin/update`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({
        current_password: currentAdmin.currentPassword,
        new_username: currentAdmin.newUsername || undefined,
        new_password: currentAdmin.newPassword || undefined,
      }),
    });
    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(translateBackendMessage(errorData.detail));
    }
    setSuccess(t("successUpdate"));
    setCurrentAdmin({
      currentPassword: "",
      newUsername: "",
      newPassword: "",
    });
  } catch (err) {
    setError(translateBackendMessage(err.message));
  }
};

Allows the admin to change username and password via /admin/update.

Rendering of the component

The AdminDashboard component displays:

  1. A language selector (it/en).
  2. User list with delete and edit buttons.
  3. Edit form for the selected user.
  4. Form to add a new user.
  5. Form to update the admin.

Exporting the component

export default AdminDashboard;

Allows you to import it into other files.

LoginPage.jsx

The LoginPage component is responsible for managing the user login, allowing them to enter their credentials and verify with the backend.

Importing Dependencies

import React, { useState } from "react";
import config from '../config';
import "../styles/LoginPage.css";

React and useState: imported for local state management.

config.js: contains the backend URL for authentication.

LoginPage.css: style of the login form.

Local state management

const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");

username and password contain the credentials entered by the user.

error is used to display an error message in case of failed login.

Login management function

const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");
    try {
        const response = await fetch(`${config.API_BASE_URL}/login`, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({ username, password }),
        });
        if (!response.ok) {
            throw new Error("Login failed");
        }
        const data = await response.json();
        onLogin(data.access_token); // Aggiorna lo stato di autenticazione
    } catch (err) {
        setError("Invalid username or password");
    }
};

Makes a POST request to /login to authenticate the user.

If login fails, an error message is displayed.

If successful, the token is passed to the onLogin() function to handle the authentication state.

Login form rendering

return (
    <div className="login-page">
        <div className="login-container">
            <h2>Login</h2>
            <form onSubmit={handleSubmit}>
                <div className="form-group">
                    <label htmlFor="username">Username:</label>
                    <input
                        id="username"
                        type="text"
                        value={username}
                        onChange={(e) => setUsername(e.target.value)}
                    />
                </div>
                <div className="form-group">
                    <label htmlFor="password">Password:</label>
                    <input
                        id="password"
                        type="password"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                    />
                </div>
                <button type="submit">Login</button>
            </form>
            {error && <p className="error-message">{error}</p>}
        </div>
    </div>
);

Login form with two fields (username and password).

Submit button that calls handleSubmit().

Error message displayed if login fails.

Exporting the component

export default LoginPage;

Allows you to import it into other files.

StatisticsPage.jsx

The StatisticsPage component displays statistics about media files and system resources using interactive tables and graphs.

Importing Dependencies

import React, { useState, useEffect } from "react";
import config from '../config';
import "../styles/StatisticsPage.css";
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  BarElement,
  ArcElement,
  Title,
  Tooltip,
  Legend,
} from "chart.js";
import { Bar, Doughnut } from "react-chartjs-2";

React and useState, useEffect: data status and update management.

config.js: contains the backend URL to retrieve statistics.

StatisticsPage.css: stylesheet for layout.

Chart.js: library for generating bar and pie charts.

Registering Chart.js Elements

ChartJS.register(CategoryScale, LinearScale, BarElement, ArcElement, Title, Tooltip, Legend);

Records the elements needed to render graphs.

State management

const [mediaStats, setMediaStats] = useState(null);
const [systemStats, setSystemStats] = useState(null);
const [language, setLanguage] = useState("en");

mediaStats and systemStats contains statistics on media files and system resources respectively.

language allows you to change the language between Italian and English.

Translations of the texts

const translations = {
  en: {
    title: "Statistics",
    mediaStatsTitle: "Media Statistics",
    systemStatsTitle: "System Statistics",
    totalPhotos: "Total Photos",
    photosLastMonth: "Photos Last Month",
    totalPhotoSize: "Total Photo Size",
    totalVideos: "Total Videos",
    videosLastMonth: "Videos Last Month",
    totalVideoSize: "Total Video Size",
    totalVideoDuration: "Total Video Duration",
    cpuUsage: "CPU Usage",
    memoryUsage: "Memory Usage",
    diskUsage: "Disk Usage",
    swapUsage: "Swap Usage",
    minutes: "minutes",
  },
  it: {
    title: "Statistiche",
    mediaStatsTitle: "Statistiche Media",
    systemStatsTitle: "Statistiche Sistema",
    totalPhotos: "Foto Totali",
    photosLastMonth: "Foto Ultimo Mese",
    totalPhotoSize: "Dimensione Totale Foto",
    totalVideos: "Video Totali",
    videosLastMonth: "Video Ultimo Mese",
    totalVideoSize: "Dimensione Totale Video",
    totalVideoDuration: "Durata Totale Video",
    cpuUsage: "Utilizzo CPU",
    memoryUsage: "Utilizzo Memoria",
    diskUsage: "Utilizzo Disco",
    swapUsage: "Utilizzo Swap",
    minutes: "minuti",
  },
};
const t = (key) => translations[language][key];

Strings are dynamically translated based on the selected language.

Retrieving statistics from the backend

useEffect(() => {
  const fetchStats = async () => {
    try {
      const mediaResponse = await fetch(`${config.API_BASE_URL}/media-stats`);
      const systemResponse = await fetch(`${config.API_BASE_URL}/system-stats`);
      setMediaStats(await mediaResponse.json());
      setSystemStats(await systemResponse.json());
    } catch (error) {
      console.error("Error fetching statistics:", error);
    }
  };

  fetchStats();
  const interval = setInterval(() => { fetchStats(); }, 10000);

  return () => clearInterval(interval);
}, []);

Make API requests every 10 seconds to update data.

setMediaStats and setSystemStats update the retrieved data.

User interface

if (!mediaStats || !systemStats) {
  return <p>Loading statistics...</p>;
}

If the data has not yet been uploaded, it displays a waiting message.

Language selection buttons

<div className="language-switch">
  <button onClick={() => setLanguage("it")}>
    <img src="https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Italy.svg" alt="Italian" />
  </button>
  <button onClick={() => setLanguage("en")}>
    <img src="https://upload.wikimedia.org/wikipedia/commons/a/a5/Flag_of_the_United_Kingdom_%281-2%29.svg" alt="English" />
  </button>
</div>

Change language via a set of buttons with corresponding flags.

Multimedia statistics table

<h3>{t("mediaStatsTitle")}</h3>
<table className="media-stats-table">
  <tbody>
    <tr>
      <td colSpan="2" className="section-header">{t("totalPhotos")}</td>
    </tr>
    <tr>
      <td>{t("totalPhotos")}</td>
      <td>{mediaStats.photos.total_count}</td>
    </tr>
    <tr>
      <td>{t("photosLastMonth")}</td>
      <td>{mediaStats.photos.last_month_count}</td>
    </tr>
    <tr>
      <td>{t("totalPhotoSize")}</td>
      <td>{mediaStats.photos.total_size_mb.toFixed(2)} MB</td>
    </tr>
  </tbody>
</table>

Shows the total number of photos and videos, their weight, and statistics for the last month.

Media Statistics Bar Chart

<Bar
  data={{
    labels: [t("totalPhotos"), t("totalVideos")],
    datasets: [
      {
        label: t("mediaStatsTitle"),
        backgroundColor: ["#36A2EB", "#FF6384"],
        data: [mediaStats.photos.total_count, mediaStats.videos.total_count],
      },
    ],
  }}
  options={{ maintainAspectRatio: true }}
/>

Show total photos and videos in a bar graph.

Pie chart for system resources

<Doughnut
  data={{
    labels: [t("cpuUsage"), t("memoryUsage"), t("diskUsage"), t("swapUsage")],
    datasets: [
      {
        label: "System Usage",
        data: [
          systemStats.cpu_usage_percent,
          systemStats.memory.used_mb / systemStats.memory.total_mb * 100,
          systemStats.disk.used_gb / systemStats.disk.total_gb * 100,
          systemStats.swap.used_mb / systemStats.swap.total_mb * 100,
        ],
        backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56", "#8E44AD"],
        hoverOffset: 4,
      },
    ],
  }}
  options={{
    plugins: {
      tooltip: {
        callbacks: {
          label: (tooltipItem) => {
            const label = tooltipItem.label || "";
            const value = tooltipItem.raw.toFixed(2);
            return `${label}: ${value}%`;
          },
        },
      },
    },
  }}
/>

Shows CPU, RAM, disk space and swap usage in a pie chart.

Exporting the component

export default StatisticsPage;

Makes the component available for use elsewhere in the project.

ThumbnailGrid.jsx

The ThumbnailGrid component manages the display of thumbnails of photos acquired by the video surveillance system. It also allows language selection, pagination management and opening a popup with the enlarged image.

Importing Dependencies

import React, { useEffect, useState } from "react";
import config from '../config';

import "../styles/ThumbnailGrid.css";

React and useState and useEffect hooks: allows you to manage the state and update of images.

config.js: contains the URL of the backend to retrieve photos.

ThumbnailGrid.css: style file for the layout of thumbnails.

State management

const [images, setImages] = useState([]); 
const [totalPages, setTotalPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [language, setLanguage] = useState("it");
const [selectedImage, setSelectedImage] = useState(null);

images: contains the list of photos to display.

totalPages: total number of pages available.

currentPage: page currently displayed.

pageSize: number of photos per page.

language: language currently selected.

selectedImage: image selected for enlarged preview.

Dynamic translations

const translations = {
  it: {
    pageSizeLabel: "Miniature per pagina:",
    sectionLabel: "Sezione foto",
    paginationLabel: "Pagina {currentPage} di {totalPages}"
  },
  en: {
    pageSizeLabel: "Photos per page:",
    sectionLabel: "Photos section",
    paginationLabel: "Page {currentPage} of {totalPages}"
  }
};

const t = (key) => translations[language][key];

Translations for Italian and English, used for UI.

t() returns the value of the string translated according to the current language.

Missing image placeholders

const placeholderImage = "https://placehold.co/150x150?text=Not+Found";

If a photo fails to load correctly, a placeholder image is displayed.

Function to recover paginated photos

const fetchPhotos = async (page, size) => {
  try {
    const response = await fetch(
      `${config.API_BASE_URL}/photos/?page=${page}&page_size=${size}`
    );
    const data = await response.json();
    setImages(data); 

    const countResponse = await fetch(
      `${config.API_BASE_URL}/photos/count/?page_size=${size}`
    );
    const countData = await countResponse.json();
    setTotalPages(countData.total_pages);
  } catch (error) {
    console.error("Errore durante il caricamento delle foto:", error);
  }
};

Makes two API calls:

  1. Gets images of the current page.
  2. Gets the total number of pages available.

The data is saved in the state images and totalPages.

Loading photos at startup and every page change

useEffect(() => {
  fetchPhotos(currentPage, pageSize);
}, [currentPage, pageSize]);

Executes fetchPhotos() when the page or photo size per page changes.

Language selector

<div className="language-switch">
  <button className="btn btn-outline-primary" onClick={() => setLanguage("it")}>
    <img src="https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Italy.svg" alt="Italian" />
  </button>
  <button className="btn btn-outline-primary" onClick={() => setLanguage("en")}>
    <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Flag_of_the_United_Kingdom_%281-2%29.svg/1920px-Flag_of_the_United_Kingdom_%281-2%29.svg.png" alt="English" />
  </button>
</div>

Allows the user to select the interface language via two flag buttons.

Selector of the number of images per page

<div className="page-size-selector">
  <label htmlFor="pageSize">{t("pageSizeLabel")}</label>
  <select
    id="pageSize"
    value={pageSize}
    onChange={(e) => {
      setPageSize(Number(e.target.value));
      setCurrentPage(1);
    }}
  >
    <option value={5}>5</option>
    <option value={10}>10</option>
    <option value={20}>20</option>
    <option value={50}>50</option>
  </select>
</div>

Allows the user to select the number of photos to display per page. Resets pagination to the first page after each change.

Viewing images

<div className="thumbnail-grid">
  {images.map((image) => (
    <img
      key={image.id}
      src={`${config.API_BASE_URL}/download-thumbnail/${image.id}`}
      alt={image.filename}
      title={image.filename}
      className="thumbnail"
      onError={(e) => (e.target.src = placeholderImage)}
      onClick={() => setSelectedImage(`${config.API_BASE_URL}/download/${image.id}`)}
    />
  ))}
</div>

Shows all available image thumbnails.

If an image fails to load, use the fallback image.

Clicking on a thumbnail opens a larger version in a popup.

Pagination management

<div className="pagination">
  <button
    onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
    disabled={currentPage === 1}
  >
    &lt;
  </button>
  <span>
    {t("paginationLabel")
      .replace("{currentPage}", currentPage)
      .replace("{totalPages}", totalPages)}
  </span>
  <button
    onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
    disabled={currentPage === totalPages}
  >
    &gt;
  </button>
</div>

Navigation buttons allow you to move between pages of images.

Text is dynamically updated through translations.

Popup with enlarged image

{selectedImage && (
  <div className="popup">
    <div className="popup-content">
      <button className="close-popup" onClick={() => setSelectedImage(null)}>
        &times;
      </button>
      <img src={selectedImage} alt="Selected" />
    </div>
  </div>
)}

When the user clicks on an image, a popup opens showing the enlarged version.

The popup can be closed by clicking the “X”.

Exporting the component

export default ThumbnailGrid;

Allows you to import ThumbnailGrid into other application files.

UserSettings.jsx

The UserSettings component allows authenticated users to change their password. It includes support for Italian and English localization and handles API requests to update the password in the backend.

Importing Dependencies

import React, { useState } from "react";
import config from '../config';

import "../styles/UserSettings.css";

React and hooks useState: to manage the state of the form data.

config.js: contains the backend URL for API calls.

UserSettings.css: style file for formatting the component.

State management

const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [language, setLanguage] = useState("it"); 

currentPassword: stores the current password of the user.

newPassword: stores the new password that the user wants to set.

error: contains any error messages received from the backend.

success: contains confirmation messages for updating the password.

language: keeps track of the language selected by the user.

Dynamic translations

const translations = {
  it: {
    title: "Impostazioni Utente",
    currentPassword: "Password Attuale:",
    newPassword: "Nuova Password:",
    changePasswordButton: "Cambia Password",
    "Password attuale errata": "Password attuale errata",
    "Password aggiornata con successo": "Password aggiornata con successo",
  },
  en: {
    title: "User Settings",
    currentPassword: "Current Password:",
    newPassword: "New Password:",
    changePasswordButton: "Change Password",
    "Password attuale errata": "Incorrect current password",
    "Password aggiornata con successo": "Password successfully updated",
  },
};

const t = (message) => translations[language]?.[message] || message;

Defines the user interface texts in Italian and English.

The t(message) function returns the translated message based on the selected language.

Password change management function

const handleChangePassword = async (e) => {
  e.preventDefault();
  setError(""); 
  setSuccess("");

  const token = localStorage.getItem("token");

  try {
    if (!token) {
      throw new Error("Nessun token trovato. Effettua nuovamente il login.");
    }

    const response = await fetch(`${config.API_BASE_URL}/users/change-password`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({
        current_password: currentPassword,
        new_password: newPassword,
      }),
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(t(errorData.detail)); 
    }

    const data = await response.json();
    setSuccess(t(data.info)); 
    setCurrentPassword("");
    setNewPassword("");
  } catch (err) {
    setError(err.message);
  }
};

Retrieves token from local storage for authentication. Sends POST request to backend with current password and new password. Handles errors and successes:

  • If the answer is negative, it displays a translated error message.
  • If the answer is positive, it clears the form and displays a confirmation message.

User interface and language selection

<div className="language-switch">
  <button onClick={() => setLanguage("it")}>
    <img
      src="https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Italy.svg"
      alt="Italian"
      style={{ width: "30px", height: "20px" }}
    />
  </button>
  <button onClick={() => setLanguage("en")}>
    <img
      src="https://upload.wikimedia.org/wikipedia/commons/a/a5/Flag_of_the_United_Kingdom_%281-2%29.svg"
      alt="English"
      style={{ width: "30px", height: "20px" }}
    />
  </button>
</div>

Allows the user to change languages ​​by clicking on a flag icon.

Module structure

<h2>{t("title")}</h2>
{error && <p className="error-message">{error}</p>}
{success && <p className="success-message">{success}</p>}
<form onSubmit={handleChangePassword}>
  <label>
    {t("currentPassword")}
    <input
      type="password"
      value={currentPassword}
      onChange={(e) => setCurrentPassword(e.target.value)}
      required
    />
  </label>
  <label>
    {t("newPassword")}
    <input
      type="password"
      value={newPassword}
      onChange={(e) => setNewPassword(e.target.value)}
      required
    />
  </label>
  <button type="submit">{t("changePasswordButton")}</button>
</form>

Shows page title based on selected language.

Shows any error or success messages.

Contains the form with fields for the current and new password.

“Change Password” button submits the form for update.

Exporting the component

export default UserSettings;

Allows you to import UserSettings into other application files.

VideoGrid.jsx

It is basically the twin of ThumbnailGrid.jsx, except that instead of managing photos it manages videos. The layout logic, the selector of the number of items per page and the popup system for viewing are identical, only the type of content changes.

import React, { useEffect, useState } from "react";
import config from '../config';

import "../styles/VideoGrid.css";

function VideoGrid() {
  const [videos, setVideos] = useState([]); // Current videos
  const [totalPages, setTotalPages] = useState(0); // Total pages
  const [currentPage, setCurrentPage] = useState(1); // Current page
  const [pageSize, setPageSize] = useState(10); // Number of videos per page
  const [language, setLanguage] = useState("it"); // Selected language
  const [popupVideo, setPopupVideo] = useState(null); // Video to show in popup

  const translations = {
    it: {
      pageSizeLabel: "Video per pagina:",
      sectionLabel: "Sezione video",
      paginationLabel: "Pagina {currentPage} di {totalPages}",
    },
    en: {
      pageSizeLabel: "Videos per page:",
      sectionLabel: "Video section",
      paginationLabel: "Page {currentPage} of {totalPages}",
    },
  };

  const t = (key) => translations[language][key];

  // Function to get paginated videos
  const fetchVideos = async (page, size) => {
    try {
      const response = await fetch(
        `${config.API_BASE_URL}/videos/?page=${page}&page_size=${size}`
      );
      const data = await response.json();
      setVideos(data); // Update videos

      const countResponse = await fetch(
        `${config.API_BASE_URL}/videos/count/?page_size=${size}`
      );
      const countData = await countResponse.json();
      setTotalPages(countData.total_pages); // Use total_pages from backend
    } catch (error) {
      console.error("Errore durante il caricamento dei video:", error);
    }
  };

  // Load videos on startup and when page or pageSize changes
  useEffect(() => {
    fetchVideos(currentPage, pageSize);
  }, [currentPage, pageSize]);

  return (
    <div>
      <div className="language-switch">
        <button className="btn btn-outline-primary" onClick={() => setLanguage("it")}>
          <img
            src="https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Italy.svg"
            alt="Italian"
            style={{ width: "30px", height: "20px" }}
          />
        </button>
        <button className="btn btn-outline-primary" onClick={() => setLanguage("en")}>
          <img
            src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Flag_of_the_United_Kingdom_%281-2%29.svg/1920px-Flag_of_the_United_Kingdom_%281-2%29.svg.png"
            alt="English"
            style={{ width: "30px", height: "20px" }}
          />
        </button>
      </div>

      <h1>{t("sectionLabel")}</h1>

      {/* Selecting the number of videos per page */}
      <div className="page-size-selector">
        <label htmlFor="pageSize">{t("pageSizeLabel")}</label>
        <select
          id="pageSize"
          value={pageSize}
          onChange={(e) => {
            setPageSize(Number(e.target.value)); // Update the number of videos per page
            setCurrentPage(1); // Reset to first page
          }}
        >
          <option value={5}>5</option>
          <option value={10}>10</option>
          <option value={20}>20</option>
          <option value={50}>50</option>
        </select>
      </div>

      <div className="video-grid">
        {videos.map((video) => (
            <div key={video.id} className="video-thumbnail">
            <img
                src={`${config.API_BASE_URL}/videos/thumbnails/${video.filename}.jpg`}
                alt={video.filename}
                title={video.filename}
                onClick={() => setPopupVideo(video)}
            />
            </div>
        ))}
    </div>


      {/* Pagination controls */}
      <div className="pagination">
        <button
          onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
          disabled={currentPage === 1}
        >
          &lt;
        </button>
        <span>
          {t("paginationLabel")
            .replace("{currentPage}", currentPage)
            .replace("{totalPages}", totalPages)}
        </span>
        <button
          onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
          disabled={currentPage === totalPages}
        >
          &gt;
        </button>
      </div>

      {/* Popup for video playback */}
      {popupVideo && (
        <div className="popup" onClick={() => setPopupVideo(null)}>
          <div className="popup-content" onClick={(e) => e.stopPropagation()}>
            <button className="close-popup" onClick={() => setPopupVideo(null)}>
              &times;
            </button>
            <video controls>
              <source
                src={`${config.API_BASE_URL}/videos/download/${popupVideo.id}`}
                type="video/mp4"
              />
              Your browser does not support the video tag.
            </video>
            <p>{popupVideo.filename}</p>
          </div>
        </div>
      )}
    </div>
  );
}

export default VideoGrid;

Let’s just look at the key differences compared to ThumbnailGrid.jsx:

  1. Managing videos instead of images
    • Instead of photos, the page loads videos from the backend via the videos/ endpoint.
    • The code to get the total number of pages and handle pagination works the same way.
  2. Video thumbnails
    • Uses ${config.API_BASE_URL}/videos/thumbnails/${video.filename}.jpg to retrieve the preview of each video, while in ThumbnailGrid.jsx the endpoint is used to download photo thumbnails.
  3. Popup for video playback
    • When a user clicks on a thumbnail, a popup opens with an <video> element that allows direct playback.
    • The video source is loaded by ${config.API_BASE_URL}/videos/download/${popupVideo.id}, then the backend needs to provide the MP4 file.
  4. Slightly different grid structure
    • In ThumbnailGrid.jsx each element is an <img>, while here each element is a <div>
      with an <img> acting as a preview, and a click opens the video popup.

config_manager.html

The config_manager.html file is a web interface that allows the administrator to manage and modify the system configuration directly from the browser.
This page allows you to update parameters of both config.ini and motion.conf, two key files for the operation of the video surveillance platform.

The interface provides:

  • Input fields to change configuration values.
  • Checkboxes and selection buttons to enable/disable specific functions.
  • Section dedicated to motion.conf, to configure the behavior of motion detection.
  • Saving changes via POST API to update configuration files.
  • Support for multiple languages ​​(Italian and English), thanks to a translation system.
  • Service restart button, to apply changes by restarting the entire system (videosurveillance.service).

Including libraries and page styling

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootswatch/5.1.3/lux/bootstrap.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">

The page uses Bootstrap for formatting and Bootstrap Icons for some management icons.

A custom style is also applied to improve the usability of the interface.

Switch for changing language

<div class="language-switch">
    <button class="btn btn-outline-primary" onclick="setLanguage('it')">
        <img src="https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Italy.svg" alt="Italian">
    </button>
    <button class="btn btn-outline-primary" onclick="setLanguage('en')">
        <img src="https://upload.wikimedia.org/wikipedia/commons/a/a5/Flag_of_the_United_Kingdom_%281-2%29.svg" alt="English">
    </button>
</div>

This section allows the user to change the interface language between Italian and English, dynamically updating the texts.

Config.ini Management

The config.ini file contains various operating parameters. This section provides input fields to change them directly.

Example: Changing the initialization delay

<label for="initDelay" class="form-label">Initial Delay (seconds):</label>
<input type="number" class="form-control" id="initDelay" placeholder="Initial delay to start monitoring photos.">

The user can change the initial delay for starting photo monitoring.

The same approach is used for:

  • Image processing interval.
  • Maximum number of photos/videos to keep.
  • Batch size for upload.

Notification Management (Pushover, Telegram, Gmail)

For each notification system, the page provides:

  • Checkbox to enable/disable the service.
  • Input fields for API tokens, user keys, and other credentials.
  • Button to show/hide credentials (for security).

Example: Enabling Telegram Notifications

<input type="checkbox" class="form-check-input" id="telegramEnabled">
<label for="telegramEnabled" class="form-check-label">Enable or disable notifications via Telegram.</label>

If enabled, the user can enter the API token and chat ID to receive notifications.

Motion.conf Management

This section allows you to change the behavior of the motion detection system.

Selecting the output type

<select class="form-select" id="outputMode">
    <option value="photos">PHOTOS</option>
    <option value="videos">VIDEOS</option>
</select>

The user can choose whether to save only photos or only videos.

Other configurable parameters

  • Motion detection threshold (threshold)
  • Minimum number of frames to generate an event (minimum_motion_frames)
  • Acceptable noise level (noise_level)
  • Despeckle filter (despeckle_filter)
  • Automatic brightness adjustment (auto_brightness)

Saving the configuration

When the user changes the parameters, he can send the new configurations to the server via a REST API.

fetch("/config", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
        general: configIni.general,
        notification: configIni.notification,
        motion: { motion_conf_content: motionConfig }
    })
})

The changes are sent to the backend, which updates the config.ini and motion.conf files.

Restarting the service

To apply the new settings, the page provides a button to apply the changes by restarting the entire system (videosurveillance.service).

fetch("/restart-service", {
    method: "POST",
    headers: { "Content-Type": "application/json" }
})

After reboot, the system will start operating with the new parameters.

The config_manager.html file provides an advanced graphical interface for system management, eliminating the need to manually edit configuration files.
With support for multiple languages ​​and the ability to edit both config.ini and motion.conf, it allows complete control of the system directly from the browser.

Docker scripts description, how to install BE and FE

The video surveillance system is containerized using Docker and Docker Compose to ensure modularity, portability and easier dependency management.

The architecture is based on three main files:

  1. Backend Dockerfile: configures and starts the FastAPI server with Motion and monitoring.
  2. Frontend Dockerfile: builds and serves the React application with Nginx.
  3. docker-compose.yml: orchestrates the backend and frontend, managing volumes, networks, and configurations.

Objectives of containerization

✔️ Isolation: each component (backend, frontend) runs in a separate container.
✔️ Portability: the system can run on any machine with Docker installed.
✔️ Scalability: the system can be expanded without dependencies.
✔️ Easy to deploy: just one command to start everything.

The docker-compose.yml file

The docker-compose.yml file is the heart of the video surveillance system orchestration. It defines two services:

  • backend (FastAPI with Motion)
  • frontend (React served with Nginx)

General structure

version: "3.3"
services:
  backend:
    ...
  frontend:
    ...

version: “3.3“: specifies the version of Docker Compose you are using.

services: defines the containers that will be executed.

Backend configuration

backend:
    build:
      context: .
    container_name: videosurveillance_backend
    ports:
      - "8000:8000"
    volumes:
      - .:/app
      - ./photos:/app/photos
      - ./videos:/app/videos
      - ./data:/app/data
      - ./config.ini:/app/config.ini
      - ./motion.conf:/etc/motion/motion.conf
      - /var/run/docker.sock:/var/run/docker.sock
      - /etc/localtime:/etc/localtime:ro
      - /etc/timezone:/etc/timezone:ro
    environment:
      - PYTHONUNBUFFERED=1
    devices:
      - "/dev/video0:/dev/video0"
    network_mode: "host"

🔹 build:

  • Specifies that the backend Docker image will be built from the Dockerfile in the current directory (.).

🔹 container_name:

  • Gives the container a static name (videosurveillance_backend), useful for referring to it in commands and logs.

🔹 ports:

  • Maps container port 8000 to host port 8000 → allows access to the FastAPI API.

🔹 volumes:

  • Allows data persistence between container and host:
    • ./photos:/app/photos → keeps saved photos even after container restart.
    • ./videos:/app/videos → same thing for videos.
    • ./data:/app/data → maintains SQLite database.
    • ./config.ini:/app/config.ini → maintains configuration settings.
    • ./motion.conf:/etc/motion/motion.conf → allows you to change the Motion configuration without recreating the container.
    • /var/run/docker.sock:/var/run/docker.sock → allows the container to control other containers, useful for automatic restarts.
    • /etc/localtime:/etc/localtime:ro e /etc/timezone:/etc/timezone:ro → synchronizes your time zone with the host’s time zone.

🔹 environment:

  • PYTHONUNBUFFERED=1 → disables Python stdout/stderr buffering for immediate log viewing.

🔹 devices:

  • “/dev/video0:/dev/video0”connects the host’s webcam to the container, allowing Motion to access the camera.

🔹 network_mode: “host”

  • Allows the container to directly use the host network, which is necessary to communicate with the camera.

Frontend configuration

frontend:
    build:
      context: ./frontend
    container_name: videosurveillance_frontend
    ports:
      - "3000:80"
    volumes:
      - ./frontend/src:/app/src
      - ./frontend/public:/app/public
    command: >
      nginx -g 'daemon off;'

🔹 build:

  • The frontend is built from the ./frontend folder, which contains its Dockerfile.

🔹 container_name:

  • Give the container a static name (videosurveillance_frontend).

🔹 ports:

🔹 volumes:

  • Allows you to update the frontend code without rebuilding the container:
    • ./frontend/src:/app/src → synchronizes React code.
    • ./frontend/public:/app/public → synchronizes public resources.

🔹 command:

  • Starts Nginx in “foreground” mode (daemon off;), so the container stays alive.

The Dockerfile for the Backend

The backend Dockerfile defines the environment needed to run the FastAPI server, the Motion software for video recording and file monitoring.

Base Image and Initial Settings

# Use the official Python image
FROM python:3.9-slim

Use the official Python 3.9 slim image, a lightweight version that includes only the essential packages.

# Set the frontend to be non-interactive to avoid warning messages during installation
ENV DEBIAN_FRONTEND=noninteractive

ENV TZ=Europe/Rome

DEBIAN_FRONTEND=noninteractive: avoids interactive prompts during package installation.

TZ=Europe/Rome: sets the environment time zone to synchronize logs and events.

User settings and permissions

# Run as root user to ensure full permissions
USER root

# Add root user to Docker group
RUN groupadd -f docker &amp;&amp; usermod -aG docker root

The container runs as the root user, which is required to control Motion and Docker.

Adds root to the docker group, allowing it to control other containers (useful for automatic restarts).

Creating the necessary folders

# Create destination folders for images and videos
RUN mkdir -p /app/photos/motion /app/videos/motion

Create folders where Motion will save photos and videos before they are processed. This ensures that, even on the first run, the paths already exist.

Installing system dependencies

# Installs build tools and system dependencies, including those for motion, pillow, and package management
RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    build-essential \
    g++ \
    cmake \
    ninja-build \
    apt-utils \
    gcc \
    python3-dev \
    libjpeg-dev \
    zlib1g-dev \
    libpng-dev \
    libffi-dev \ 
    motion \
    docker.io \ 
    ffmpeg  \
    curl \
    &amp;&amp; rm -rf /var/lib/apt/lists/* 

Installs Motion, FFmpeg, and other build tools required by some Python libraries.

motion: motion detection and video recording software.

ffmpeg: required for processing video and generating thumbnails.

docker.io: allows the container to execute Docker commands.

rm -rf /var/lib/apt/lists/* : reduces space occupied by deleting apt-get temporary files.

Installing Python Dependencies

# Set working directory in container
WORKDIR /app

# Copy the requirements.txt file to your working directory
COPY requirements.txt .

# Update pip before installing dependencies
RUN pip install --upgrade pip setuptools wheel

# Install precompiled numpy before other dependencies
RUN pip install --no-cache-dir numpy==2.0.2

# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt

RUN pip install --no-cache-dir PyJWT

RUN pip install apscheduler

# Install psutil for system resource monitoring
RUN pip install --no-cache-dir psutil

# List Python dependencies
RUN pip list

# Install PIL separately if not already present
RUN pip install pillow

Sets working folder to /app.

Installs all dependencies from the requirements.txt file.

Installs Numpy 2.0.2 before other packages to avoid compatibility issues.

Installs PyJWT to manage JWT authentication.

Installs APScheduler to manage periodic operations (e.g. backup).

Installs Psutil to monitor system resources.Checks the list of installed dependencies with pip list.

Copying code into container

# Copy all the rest of the project files into the working directory
COPY . .

# Copy the contents of the static folder into the container
COPY ./static /app/static

Copies all the source code into the container.

Makes sure the static folder (containing files like CSS, images, etc.) is present.

Opening the port and starting the service

# Expose port 8000 (FastAPI uses this port by default)
EXPOSE 8000

Exposes port 8000, which is required to access the FastAPI server.

# Command to start Motion, FastAPI app and monitoring script in parallel
CMD ["sh", "-c", "motion &amp; uvicorn main:app --host 0.0.0.0 --port 8000 &amp; python3 /app/motion_monitor.py"]

Starts Motion, FastAPI server and monitoring script in parallel.

  1. motion &: runs the motion detection software.
  2. uvicorn main:app –host 0.0.0.0 –port 8000 &: starts the FastAPI server for APIs.
  3. python3 /app/motion_monitor.py: starts photo/video monitoring.

The Dockerfile for the Frontend

The frontend Dockerfile is responsible for building and deploying the React application. It uses Node.js to compile the code and Nginx to serve the application in production.

Creating the build environment

# Use Node.js image to build the app
FROM node:18-alpine AS build

Uses Node.js 18 Alpine, a lightweight version of Node.js, to reduce the image weight.

Defines a “build stage” called build, which will be used to compile the React app.

# Check npm
RUN npm --version &amp;&amp; echo "npm is installed correctly"

Verifies that npm (Node Package Manager) is installed and running. Prints a confirmation message to the log.

Installing dependencies

# Add Git (required for some dependencies)
RUN apk add --no-cache git

# Set the working directory
WORKDIR /app

# Automatically creates package.json and package-lock.json
RUN npm init -y

# Add the "build" script to the package.json
RUN sed -i 's/"scripts": {/"scripts": {"build": "vite build",/g' package.json

# Install basic dependencies (vite, react, react-dom)
RUN npm install [email protected] [email protected] [email protected]
RUN npm install --save-dev vite

# Install Chart.js and the React wrapper
RUN npm install chart.js react-chartjs-2

Installs Git, which is required for some NPM dependencies, using –no-cache to avoid saving temporary files, reducing the container’s weight.

Sets /app as the working directory for all subsequent commands.

Automatically creates a package.json, the configuration file for NPM packages. The -y flag accepts all default settings.

Edits package.json to add the “build” command: “vite build”. This will allow you to use npm run build to compile the project with Vite.

Installs the basic React libraries (react, react-dom, react-router-dom). Installs Vite, a fast bundler for React.

Installs Chart.js and the React wrapper, which are needed to display charts in your app.

Copying project files

# Copy the custom files above the generated ones
COPY ./index.html /app/index.html
COPY ./src /app/src

# Add a command to list files
RUN ls -l /app/src/styles/ &amp;&amp; echo "Checking files"

Copies index.html and the src/ folder (containing the React code) into the container directory. Avoids copying the entire project, taking only the files needed for the build.

Lists the files in the /app/src/styles/ folder to verify that they were copied correctly. Prints a confirmation message.

React App Build

RUN npm run build

Compiles the application with Vite, generating static files in the /app/dist folder. Optimizes the code for production.

Creating a Production Environment with Nginx

# Use Nginx to serve the app
FROM nginx:alpine

# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf

Uses Nginx Alpine, a lightweight version of Nginx, to serve your React application. This allows you to deploy your app without the need for Node.js in the final container.

Copies a custom Nginx configuration file, which defines how to serve the React app and contains rules for routing API requests to the backend.

Below is the nginx.conf file:

server {
    listen 80;

    server_name localhost;

    root /usr/share/nginx/html;

    index index.html;

    location / {
        try_files $uri /index.html;
    }

    error_page 404 /index.html;
}

The Dockerfile for the Frontend continues with:

# Copy the build into the Nginx container
COPY --from=build /app/dist /usr/share/nginx/html

Copies compiled files (/app/dist) from the build phase to the default Nginx folder (/usr/share/nginx/html). This allows Nginx to serve React files directly without having Node.js running.

Opening the port and starting the server

# Expose port 80
EXPOSE 80

# Command to start Nginx
CMD ["nginx", "-g", "daemon off;"]

Exposes port 80, on which Nginx will serve the React app. Starts Nginx in foreground mode (daemon off;), so the container stays alive.

Rest APIs available on BE and their list

The video surveillance system offers a complete set of REST APIs, allowing advanced users and developers to interact with the backend programmatically. Through these APIs, you can retrieve photos and videos, change configuration settings, and manage the system without directly accessing the GUI.

To view the complete list of APIs and test them directly, an interactive documentation based on Swagger/OpenAPI is available.

🔗 Access the API documentation:
👉 Here is the link to view the APIs http://192.168.1.190:8000/docs

Below is a screenshot of the API documentation, showing the structure of the interface and some of the available operations:

List of available REST APIs
List of available REST APIs

API Key Features

The APIs allow you to:
✅ Retrieve and download photos and videos acquired by the system.
✅ Configure video surveillance parameters (e.g. maximum number of photos/videos, notifications, etc.).
✅ Monitor system status and view statistics on resources used.
✅ Perform administrative operations such as restarting the backend or updating settings.

Thanks to this programmatic interface, the system can be easily integrated with other applications or automated according to the user’s needs.

Video Surveillance System Installation Process Summary

Installing the Raspberry Pi video surveillance system is designed to be easy and automated, with just a few simple steps ensuring a hassle-free setup. Here’s an overview of the process:

📦 1. Preparing the Raspberry Pi

  • Flash the configured OS image or manually install the official OS.
  • Initial Raspberry Pi setup, such as network setup, fixed IP, timezone, and camera enablement.
  • Connect via SSH to continue with the installation commands.

🛠️ 2. Installation of essential components

Run the first script install_docker.sh which takes care of:

  1. Configure swap to 2 GB, to avoid problems when compiling packages like numpy.
  2. Install Docker and Docker Compose, which are needed for the backend and frontend of the system.
  3. Add the user to the Docker group, avoiding constant use of sudo in Docker commands.

📌 Note: after running install_docker.sh, reboot your Raspberry Pi to apply all changes.

⚙️ 3. Video Surveillance System Setup

Once the Raspberry Pi has rebooted, run the setup_videosurveillance.sh script:

  • Creates Docker containers for the backend and frontend.
  • Configures file and folder permissions to ensure proper management of images, videos and database.
  • Starts the system services (videosurveillance.service and host_service.service) and sets them to start automatically.

🔄 4. System Test

  • Verify that the system is active by consulting the service logs.
  • Access the web interface to test the system operation.
  • Configure any notifications via Telegram, Gmail or Pushover.

🔐 5. Optional: External Access Configuration

If you want to access the system from outside the local network, you can set up WireGuard or use a VPN connection.

Web interface guide: how to manage your video surveillance system

Introduction

The web interface is the meeting point between user and system. Simple but powerful, it allows you to manage every aspect of the video surveillance system in just a few clicks.
From viewing photos and recorded videos, to advanced system configuration, through user management and resource monitoring, this interface has been designed to ensure usability and quick access to the most important information.

The interface is divided into several sections:

  • User management, with full control for the administrator.
  • Viewing photos and videos, organized in pageable galleries.
  • System configuration, to modify technical and operational parameters.
  • System statistics, to monitor the status of resources and video surveillance activity.
  • Backup, with the ability to directly download a backup file of the entire system.

Now let’s see how to access and use the various features, starting from user management.

User Management: Administrator and Regular Users

Access to the video surveillance system is regulated via a login page. Only registered users can access the system’s features.
There are two types of accounts:

  • Administrator (Admin): has full access to all features and can manage the system in all its aspects. The administrator is unique and cannot be deleted. Its default password is 123456 and it is strongly recommended to change it at the first login.
  • Regular users: have limited access and can view photos, videos and statistics, as well as change their password but cannot change system configuration or manage other users.

Login page

The interface opens with the login page, where the user must enter his username and password. The default admin user is already configured and can be used for the first login. Later, the administrator can add new users or change the login credentials.

It is strongly recommended that the user who will assume the role of administrator change the default password (123456) associated with the admin user for security reasons.

The following photo shows the login page:

Login page
Login page

The following photo shows the error screen if you enter incorrect credentials:

Error screen if incorrect credentials are entered
Error screen if incorrect credentials are entered

User Management (Admin only)

The administrator has complete control over users through a dedicated section. He can:

  • add new users
  • edit existing users (user and/or password)
  • delete normal users (but not the admin itself)

There is only one administrator, there is no possibility of adding a second administrator. The administration can, through a dedicated section of the page, change their userid and/or password.

The following image shows the user management screen with user list and Add, Edit, Delete buttons:

User management screen
User management screen

The following video shows how, from the administration panel, you can manage users by viewing their list and being able to add, modify or delete them from the system:

Personal management (for normal users)

Every normal user can access the section dedicated to managing their password, where they can change it. It is not possible for normal users to access the advanced management or configuration features of the system.

To improve security, it is better to choose strong passwords.

The following image shows the normal user password setting page:

Normal user password setting page
Normal user password setting page

The following video shows how a user can access their “User Settings” page to change their password:

📌 Session security

To ensure data protection, the system implements an automatic session timeout. If a user remains inactive for a long period of time, they will be automatically logged out and redirected to the login page. This prevents unauthorized access if the device is left unattended.

Photo management

The Photos section allows you to view all the images captured by the video surveillance system in a clear and organized interface. Each image is displayed as a thumbnail, arranged in a paginated grid to facilitate navigation even with a large volume of data.

Navigation in the photo section

  • Image thumbnails are sorted chronologically, with the most recent photos at the top
  • By clicking on a thumbnail, a larger view opens to observe the image in detail
  • A pagination bar allows you to easily move between pages, if the number of images is greater than can be displayed on a single screen
  • A drop-down menu allows you to choose the number of thumbnails shown per page

Available features

  • Enlarged image display: each image can be displayed in a larger size for better observation
  • Downloading images: you can download any image by right-clicking on the photo and clicking “download image” on the menu. From smartphone/tablet: tap and hold on the image and choose “Save image”.
  • Immutability of images: images present in the system cannot be deleted either by normal users or by the administrator.

The following image shows the image grid screen with pagination:

Image grid screen with pagination
Image grid screen with pagination

The following image shows a screenshot of the enlarged view of a photo:

Screenshot of the enlarged view of a photo
Screenshot of the enlarged view of a photo

The following image shows the photo download menu on PC:

Photo download menu on PC
Photo download menu on PC

The following video shows how to view the photo section, navigate between pages, open a specific photo and save it to your device:

Video management

The Video section provides an interface to view and manage all the videos recorded by the video surveillance system. As with photos, videos are displayed as thumbnails, with a paginated grid layout for easy and intuitive navigation.

Navigation in the video section

  • Video thumbnails are sorted chronologically, with the most recent ones at the top of the list
  • By clicking on a thumbnail, an integrated video player opens, allowing you to view the content directly within the web interface
  • Pagination allows you to move between pages to view all available videos
  • A drop-down menu allows you to choose the number of thumbnails shown per page

Available features

  • Video playback: any video can be played directly from the interface without the need to download it
  • Viewing options: during playback, you can use functions such as full-screen view, progress bar for complete control, playback speed adjustment, video download, picture-in-picture function
  • Video download: a dedicated button in the player’s three-dot menu allows you to download videos locally for analysis or personal storage
  • Video immutability: even for videos, there is no deletion function via web interface

The following image shows the video gallery screen, with thumbnails and pagination:

Video gallery screen, with thumbnails and pagination
Video gallery screen, with thumbnails and pagination

The following image shows the video player opened:

The image shows the video player open
The image shows the video player open

The following image shows the video player with the menu of possible options opened:

The image shows the video player with the menu of possible options opened
The image shows the video player with the menu of possible options opened

The following video shows how to view the video section, navigate between pages, open a specific video and perform operations on it:

System configuration

The Configuration Management section is accessible only to the administrator and allows you to customize the operational settings of the video surveillance system. This section is divided into three main areas:

  1. General Configuration (main system parameters)
  2. Notification Management (Telegram, Pushover, Email)
  3. Motion Configuration (parameters for motion detection)

After making changes to the parameters, you can save them by clicking Save Configuration and restart the video surveillance service by clicking Restart Service to make them effective.

General Configuration

This section allows you to manage the main system settings:

  • Initial Delay (seconds): wait time before monitoring begins (default: 15 seconds) to allow Motion to settle upon startup.
  • Process Interval (seconds): interval between processing photos (default: 10 seconds).
  • Max Photos: maximum number of photos to store before auto-delete (default: 500).
  • Max Videos: maximum number of videos to store before auto-delete (default: 50).
  • Batch Size: number of photos processed in a single upload batch (default: 50).

These parameters allow you to manage the system workload and image storage based on the available memory capacity.

Notification Management

The system supports three notification modes to alert the user of new detected events:

  • Pushover: direct push notifications on smartphones via the Pushover API. You need to enter your Pushover Token and User Key.
  • Telegram: sending notifications and images via Telegram bot. You need to activate the option and enter your Telegram Token and User ID.
  • Gmail: sending email notifications via Google’s SMTP server. You must specify:
    • SMTP Server (default: smtp.gmail.com)
    • SMTP Port (default: 587)
    • Email sender and receiver
    • App Password for authentication.

Notifications can be enabled or disabled via a specific checkbox for each service. The system also supports the display of passwords, which can be made visible or hidden for security reasons.

Motion Configuration (motion.conf)

This section manages the parameters of the Motion software, responsible for motion detection and photo/video capture.

  • Output Mode: selects whether to save only photos or only videos.
  • Threshold: sensitivity threshold to detect motion (default: 500).
  • Minimum Motion Frames: minimum number of frames with motion to generate an event (default: 2).
  • Event Gap: minimum time between two consecutive events (default: 15 seconds).
  • Noise Level: acceptable noise level before considering an event as valid (default: 64).
  • Noise Tune: activation of automatic filter to adjust the noise level.
  • Despeckle Filter: selection of the filter for removing noise in captured frames (default EedDl option).
  • Auto Brightness: automatic brightness adjustment to improve image quality.

This section allows you to optimize the system behavior based on environmental conditions, avoiding false positives and improving the reliability of notifications.

After each change, you must save the settings with Save Configuration and restart the system with Restart Service to apply the changes.

The configuration page
The configuration page

System Statistics and Monitoring

The Statistics section provides a detailed overview of the status of the video surveillance system, allowing you to monitor both the amount of stored media (photos and videos) and the use of hardware resources. The data is automatically updated every 10 seconds, ensuring always up-to-date information.

Media statistics

The first part of the section focuses on the statistics related to photos and videos present in the system. Here is what you will find:

  • Total Photos: the total number of photos saved
  • Last Month Photos: the number of photos taken in the last month
  • Total Photo Size: the space occupied by all photos on the system
  • Total Videos: the total number of videos recorded
  • Last Month Videos: the number of videos recorded in the last month
  • Total Video Size: the space occupied by all videos
  • Total Video Duration: the total duration of all videos in minutes

System statistics

The second part shows the system resources and their usage in real time:

  • CPU Usage (%): percentage of processor usage
  • Memory Usage (%): percentage of RAM used
  • Disk Usage (%): percentage of disk space used
  • Swap Usage (%): percentage of swap used

Charts and visualizations

To make the data more understandable, the section offers two types of graphs:

  1. Bar Chart: displays the total number of photos and videos.
  2. Doughnut Chart: displays the usage of system resources, such as CPU, memory, disk, and swap, in percentage.

These graphs help you immediately understand the overall health of your system and spot any performance issues.

The following image shows the Statistics section with the data visible and the graphs prominently displayed to show how resource usage is displayed:

Statistics page
Statistics page

Automatic backup and download

An effective video surveillance system must ensure the safety not only of the footage but also of the integrity of the data. For this reason, an automatic backup mechanism has been implemented, which periodically saves the most important data in a file called backup.zip.

Backup includes:

  • archived photos: to ensure no event is missed.
  • recorded videos: to maintain a complete history.
  • SQLite database containing system configuration and users: containing system configuration and user credentials.

Automatic backup

The backup is created automatically at regular intervals, which can be configured in the main.py file. This means that the process is completely automatic, without the need for manual intervention. This ensures that in the event of a failure or error, the data can be easily recovered using the restore_backup.sh script.

Backup download button

For the administrator’s convenience, a download button is available on the main page of the web interface. This button allows you to download the latest backup.zip file directly from your browser, without having to physically access the Raspberry Pi.

📌 Please note: the download is available only for the system administrator. Standard users cannot access this feature to ensure data security.

Logout: safely log out of the system

The Logout button, visible to all users, allows you to safely exit the web application. This feature ensures account protection and prevents unauthorized access by third parties.

What does logging out do?

  • deletes the session token used for authentication, invalidating the current access
  • redirects the user to the login page, where they will need to re-enter their credentials to access
  • protects sensitive data, preventing others from accessing the information if the device is left unattended

💡 Safety advice: it is good practice to always log out at the end of each session, especially if you are using the system on a shared or public device.

📡 Secure remote access with WireGuard

The video surveillance application works perfectly on the local network, but what if we want to access the images and videos remotely? The most secure solution is WireGuard, a modern, fast and easy to configure VPN.

Why use WireGuard for video surveillance?

WireGuard is a modern, fast and secure VPN, designed to be simpler and more efficient than older solutions like OpenVPN or IPSec. Its light weight and ease of use make it particularly suitable for low-power devices like the Raspberry Pi, which is the heart of our video surveillance system.

But why do we need a VPN?

  • Secure remote access 🔒 → WireGuard lets you connect to your home network from anywhere in the world, ensuring secure communications with advanced encryption.
  • Avoids complicated NAT and dynamic IP configurations 🚀 → if your ISP changes your public IP frequently (as happens with home connections), WireGuard allows you to always maintain a stable connection.
  • Low bandwidth and minimal latency ⚡ → WireGuard is more efficient than other VPNs, reducing CPU consumption and optimizing data traffic, which is crucial when accessing live video streaming.
  • Ease of setup 🛠️ → unlike other solutions, WireGuard requires only a few lines of configuration to get up and running.

Using WireGuard, we can securely access our video surveillance interface without having to expose the Raspberry Pi services directly to the Internet, thus avoiding potential attacks and vulnerabilities.

Common problems and preliminary checks (read before proceeding!)

Before proceeding with the installation and configuration of WireGuard, it is essential to verify some key aspects. If these requirements are not met, WireGuard may not function properly or require additional configuration.

1. Check if your router has a public IP

To access the Raspberry Pi remotely, the router must have a public IP assigned by the provider. Some providers use CG-NAT (Carrier-Grade NAT), which prevents direct connection from the outside.

🔹 How to check? Open the terminal on the Raspberry Pi and type:

curl -s -4 https://ifconfig.me

This command will show you your public IP.

Now log in to your router‘s web interface and look for the section that shows the WAN IP (usually found in the network settings or connection status).

If the public IP obtained with curl is the same as the one shown by the router, it means that you have a real public IP and can proceed.

If the addresses are different, it is likely that your provider uses CG-NAT, which makes a direct connection impossible. In this case, you should contact your provider and request a static or dynamic public IP.

Alternative: If you can’t find the public IP from your router, you can also check it from any device connected to the network by visiting the site:
👉 https://whatismyipaddress.com

2. Open UDP port 51820 in router

WireGuard uses port 51820/UDP by default for VPN connections. If this port is blocked by the router’s firewall, the client will not be able to connect.

🔹 How to open the port on the router?

  • Access the router interface (usually located at 192.168.1.1 or 192.168.0.1).
  • Look for the Port Forwarding or Virtual Server section.
  • Add a new rule:
    • Protocol: UDP
    • External port: 51820
    • Internal IP: the IP address of the Raspberry Pi (e.g. 192.168.1.190)
    • Internal port: 51820

💡 Note: if you have a firewall active on the Raspberry Pi, make sure port 51820/UDP is open there too with:

sudo ufw allow 51820/udp

Any issues with the WireGuard port and router configuration

In order to access your Raspberry Pi remotely through WireGuard, you need to open a port in your router and redirect it to the Raspberry. By default, WireGuard uses UDP port 51820, but not all routers support it.

Problems with port 51820?

If your router refuses this port, as is the case with some providers who impose limits on the available ports, you will need to choose a port between 32768 and 40959, which is the official recommended ephemeral port range for UDP.

How to choose the right port?

Check your router: if you try to set up port forwarding and get an error that the port is out of range, choose another port within 32768-40959.

Avoid ports already used by other services: do not choose ports reserved for commercial VPNs, gaming servers or VoIP.

Keep UDP: WireGuard only works with UDP protocol, so don’t try with TCP.

Then set the install_wireguard.sh script according to the port you have chosen (and open on your router). Then follow the next paragraphs to install WireGuard.

3. Make sure your Raspberry Pi has a stable connection

WireGuard creates a secure VPN tunnel between devices, so it is essential that the Raspberry Pi is always online and reachable.

🔹 Checks to do:

  • Is the Raspberry Pi connected via Ethernet cable or does it have stable Wi-Fi?
  • Do you have a static IP within your local network? To avoid problems, it is recommended to assign a static IP to the Raspberry Pi from the router (we have already assigned the static IP 192.168.1.190).
  • Does the client (phone or PC) have an active and functional data connection?

💡 If any of these steps fail, WireGuard may not function properly!
📌 Please fix these issues first before moving on to installation.

Installing WireGuard on Raspberry Pi

To simplify installation and configuration, we will use an automated script that will install WireGuard, generate keys, and configure the VPN server.

📌 Automated installation with install_wireguard.sh script

I have created a script that automates the entire process of installing and configuring WireGuard on the Raspberry Pi. This script is located in the videosurveillance project folder.

Make the script executable:

chmod +x install_wireguard.sh

Run the script:

sudo ./install_wireguard.sh

At the end of the execution, a QR code should appear on the shell that you will need shortly to pair the WireGuard application on your mobile phone with the VPN on the Raspberry. The install_wireguard.sh script will install WireGuard, configure the server and automatically generate the configuration for the client. If you want to see the server configuration file, you can find it in /etc/wireguard/wg0.conf.
Likewise, the configuration file for the client will be available in client.conf and can be imported directly into the WireGuard smartphone app via QR code. This app, available for Android and iOS, must be installed before proceeding with the client configuration, as will be explained in the next paragraph. At the end of the script execution, a QR code will be generated and displayed, which can be scanned directly from the app to automatically configure the connection.

Configuring the client on smartphone

Installing WireGuard on Android/iOS

To connect your smartphone to the Raspberry Pi via VPN, you need to install the WireGuard app, available on the main stores:

After installing the app, open it and follow the next steps.

Importing the configuration

There are two ways to import the client configuration into the phone:

📌 Method 1: Scan QR Code (recommended)

After running the install_wireguard.sh script, a QR code with the client configuration will be automatically generated. To import it:

  1. Open the WireGuard app.
  2. Tap the “+” button at the bottom right.
  3. Select “Scan a QR code from camera.”
  4. Point your smartphone camera at the QR code shown in the Raspberry Pi terminal.
  5. After scanning, give your connection a name (e.g. RaspberryPi VPN) and save.
  6. Manually change the configuration:
    • Open the connection you just created.
    • In the Peer section, find the Allowed IPs entry.
    • Replace 0.0.0.0/0 with 192.168.1.190/32.
    • Save your changes.

📌 Method 2: Manual setup

If you cannot scan the QR code, you can configure WireGuard manually:

  1. Open the WireGuard app and tap “+” at the bottom right.
  2. Select “Create from scratch”.
  3. In the setup screen:
    • Connection name → “RaspberryPi VPN”
    • Private Key → tap “Generate” to create a new key
    • IP Address → enter 10.0.0.2/32
    • DNS Server → enter 8.8.8.8 (Google DNS)
  4. Tap “Add Peer” and fill in the following:
    • Public Key → enter the server’s public key (you can find it in client.conf)
    • Endpoint → enter PUBLIC_IP:51820 (replace with your router’s public IP)
    • Allowed IPs → enter 192.168.1.190/32
    • Persistent Keepalive → 25
  5. Tap “Save” to complete the setup.

At this point you need to reboot the Raspberry with the command:

sudo reboot

Once the reboot is complete you can connect to the VPN!

Connecting to Raspberry Pi and testing operation

  1. Open the WireGuard app.
  2. Tap the “RaspberryPi VPN” connection.
  3. Activate the tunnel by tapping the ON/OFF switch.

If the connection is active, you can test it by pinging from the Raspberry Pi to your phone:

ping 10.0.0.2

or with a ping from your phone to the Raspberry Pi:

ping 192.168.1.190

If everything went well, you can access the video surveillance web interface from your phone:

http://192.168.1.190:3000

NOTE: if you need to review the QR code generated from the client.conf configuration file in the Raspberry videosurveillance project folder, give the command:

qrencode -t UTF8 < client.conf

Configuring the client on PC (strongly recommended)

Using video surveillance on a PC is the ideal solution to make the most of the interface and all the available features. The PC screen is definitely more suitable for reproducing all the elements of a complex interface like ours. Here we see how to configure WireGuard on Windows, Linux and macOS.

Installing WireGuard on Windows/Linux/macOS

First, you need to install the WireGuard client on your computer:

  • Windows: download the installer from the official WireGuard website https://www.wireguard.com/install/ and install it like a normal application.
  • Linux: open a terminal and install WireGuard with: sudo apt update && sudo apt install wireguard (on Ubuntu/Debian) or the equivalent command for your distribution.
  • macOS: download the WireGuard application from the Mac App Store or use the command: brew install wireguard-tools if you have Homebrew installed.

Generating and importing the configuration

After installing WireGuard, we need to import the client configuration.

If you have already run the install_wireguard.sh script on your Raspberry Pi, you should have the client.conf file automatically generated. Now you have two ways to import it to your PC:

  • Method 1: Manually copy the file
    1. Transfer the client.conf file from the Raspberry Pi to your PC (via USB, SCP, email, etc.).
    2. Open the WireGuard application and click Import Tunnel from File.
    3. Select client.conf and confirm.
    4. Activate the connection with the “Activate” button.
  • Method 2: Manually creating the tunnel If you can’t transfer the file, you can manually copy its contents:
    1. Open the WireGuard app.
    2. Click Add Tunnel > Create from scratch.
    3. Enter the tunnel name (e.g. “Video Surveillance”).
    4. Copy and paste the contents of client.conf into the corresponding fields.
    5. Save and activate the connection.

Access video surveillance from the browser

Once the VPN connection is active, you can access the video surveillance interface directly from the browser.

  1. Open the browser on your PC (Chrome, Firefox, Edge, etc.).
  2. Type the address 192.168.1.190:3000 in the address bar.
  3. Enter your login credentials.
  4. Now you can navigate the video surveillance interface, check the images and recorded videos with maximum comfort!

Advantages of using on PC

  • Larger screen to better view recordings.
  • More comfortable navigation with mouse and keyboard.
  • Faster speed than using a smartphone.

For these reasons, I recommend using the PC for the daily management of the video surveillance system. The smartphone can be useful for quick checks or notifications, but the full experience is obtained on the desktop!

Conclusion: secure and uncompromising remote access

Integrating WireGuard into your Raspberry Pi video surveillance system is a powerful, secure, and efficient way to access your devices from anywhere in the world.

Thanks to modern encryption and an extremely lightweight protocol, WireGuard guarantees a secure VPN tunnel without impacting the performance of the network or the Raspberry Pi.

📌 Access anywhere and in total safety
With this setup, you can monitor your video surveillance remotely without having to expose your Raspberry Pi directly to the Internet. This eliminates the risks of open ports and potential attacks.

📌 Quick and uncompromising setup
The installation and configuration are automated by the provided script, making the process quick and easy even for those with no experience with VPNs. In just a few minutes, the user can connect to their video surveillance from their smartphone or PC.

📌 Home network security preserved
Unlike other solutions that require port forwarding and directly exposing the device to the Internet, WireGuard allows remote access without compromising the security of the local network, protecting the Raspberry Pi and other connected devices.

Conclusions and possible future developments

We have created a complete video surveillance system, based on Raspberry Pi, Docker and WireGuard, capable of offering a scalable, secure and remotely accessible solution. Thanks to a combination of modern technologies, the system allows you to record videos and photos, view them through an intuitive interface and protect access with a lightweight but powerful VPN.

But the project can be further improved with the numerous possibilities of expansion and customization that you can consider to adapt it to your needs:

🔧 Possible improvements

  • Advanced notifications → implement more detailed notifications via Telegram, Gmail or Pushover or by adding other systems.
  • AI integration → use AI-based facial recognition or motion detection models to improve event detection.
  • Multi-camera support → extend support to multiple cameras to monitor different areas at the same time.
  • Cloud or NAS storage → implement an automatic backup system to a cloud service or a local NAS to have an always accessible history.
  • Improved user interface → optimize the UI for a better experience on mobile devices or add features such as advanced search filters.

With these possible evolutions, the system can transform into an even more powerful and versatile platform.

🔍 If you want to further develop or improve the project, feel free to experiment and share your changes!🚀

🔗 Other interesting projects

If you enjoyed this project, you might also find these articles useful:

Newsletter

If you want to be informed about the release of new articles, subscribe to the newsletter. Before subscribing to the newsletter read the page Privacy Policy (UE)

If you want to unsubscribe from the newsletter, click on the link that you will find in the newsletter email.

Enter your name
Enter your email

🔗 Follow us on our social channels so you don’t miss any updates!

📢 Join our Telegram channel to receive real-time updates.

🐦 Follow us on Twitter to always stay informed about our news.

Thank you for being part of our TechRM community! 🚀

0 0 votes
Article Rating
guest
2 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Jay Daugherty
Jay Daugherty
13 giorni fa

Thank you for your efforts on this project. I am currently working on a senior project involving a security surveillance autonomous robot, and I was not taught networking concepts until now.

I have two questions:

1. Can this surveillance system be used remotely, allowing the robot to operate at home while I am away at school or work and still access it?

2. Is it possible to use an updated version of the software for Raspberry Pi instead of the lite version?

2
0
Would love your thoughts, please comment.x
Scroll to Top