This project provides a lightweight, self-hosted CCTV solution designed for Linux-based single-board computers (SBCs) and standard USB webcams. It offers an affordable, privacy-focused alternative for home monitoring by keeping your video data entirely under your control.
Project Philosophy
- Privacy-First: By storing all footage locally, this system eliminates the need for third-party cloud subscriptions and ensures your data never leaves your network.
- Cost-Effective: Leverage existing hardware—such as a spare Linux board and a USB webcam—to build a fully functional surveillance system without recurring fees.
- Minimalist Architecture: The software is optimized to run efficiently on low-power devices, ensuring high performance even on entry-level hardware.
Key Features
- Hardware Agnostic: Highly compatible with a wide range of standard USB webcams.
- Resource Efficient: Optimized specifically for Linux-based boards (e.g., Raspberry Pi, Orange Pi, or similar SBCs).
- Data Sovereignty: Full control over your storage path, retention policies, and access methods.
- Simple Deployment: Designed for quick setup and easy maintenance.
- Live MJPEG stream with a web dashboard
- Motion detection with frame differencing
- Automatic recording with a pre-motion buffer
- Interactive Recordings Calendar: filter video recordings by date with pulsing red day badges
- Cloud Storage Integration:
- Direct auto-upload of motion recordings to Google Drive (via Google Drive API v3)
- Direct auto-upload of motion recordings to OneDrive (via Microsoft Graph API)
- AES-256 ZIP Upload Encryption:
- Independently encrypt video clips and snapshots before uploading to Telegram, Google Drive, or OneDrive
- Password-protected
.zipformat opens natively on iOS (Files app), Android, macOS, and Windows - Manage encryption passphrase and per-channel toggles from dashboard or Telegram bot
- Email alerts with a snapshot picture when motion starts
- Telegram integration:
- Automatic video upload after motion is recorded (supports AES-256 encrypted
.zipdelivery) - Bot commands:
/snapshot,/video <seconds>,/encryption,/encrypt_telegram_on/off,/encrypt_gdrive_on/off,/encrypt_onedrive_on/off,/sent,/delete,/delete_range,/id,/telegram_on/off,/email_on/off,/help
- Automatic video upload after motion is recorded (supports AES-256 encrypted
- Night mode low-light enhancement (software CLAHE + brightness/contrast boost, with optional second IR/night camera)
- Recordings bulk actions: select all, send to Telegram, download ZIP, delete
- Interactive Swagger UI at
/api/for the REST API - RTSP output via MediaMTX + FFmpeg (optional, disabled by default)
- Storage cleanup by age, total size, and emergency low-disk cleanup
- systemd autostart ready
- Licensed under GNU AGPLv3
- Python 3.10 or newer
- OpenCV with V4L2 support (see installation options below)
- A USB webcam (
/dev/video0by default) - Optional: a Telegram bot token for Telegram notifications
- Optional: SMTP credentials for email alerts
-
Verify OpenCV is available:
python3 -c "import cv2; print(cv2.__version__)"If OpenCV is not installed, you have two options:
- Option A — use a system OpenCV package (recommended on ARM boards such as the Odroid XU4, where a pre-built system package is usually optimized for the board).
- Option B — install OpenCV via pip (convenient on x86/amd64 machines or when no system package is available). Use
opencv-python-headlessbecause the app does not need a GUI.
-
Create and activate the virtual environment and install the app:
This creates a venv that can see the system site-packages, so the system
cv2is available inside the venv.cd $HOME/CheapSecurity python3 -m venv venv --system-site-packages source venv/bin/activate pip install -e .
Use this if you do not have a system OpenCV or prefer a self-contained venv.
cd $HOME/CheapSecurity python3 -m venv venv source venv/bin/activate pip install opencv-python-headless pip install -e .
Note: On some older ARM boards the pip
opencv-python-headlesswheel may not be available or may be slow. If that happens, install OpenCV from your distribution’s package manager instead and use Option A. -
Find your webcam device (usually
/dev/video0):v4l2-ctl --list-devices
-
Copy
config.json.exampletoconfig.jsonand edit it:cp config.json.example config.json nano config.json
- Set camera device, resolution, and frame rate
- Fill in SMTP credentials if you want email alerts
- Fill in Telegram bot token and chat ID if you want Telegram uploads
Security:
config.jsonis listed in.gitignoreand must never be committed. It contains passwords and tokens. Always editconfig.json, notconfig.json.example. If you add a new setting, update both files so the example stays in sync. -
Run the app:
./venv/bin/python -m cheapsecurity.app
-
Open the dashboard in a browser:
http://<odroid-ip>:5000
Edit config.json:
| Section | Key | Description |
|---|---|---|
camera |
device |
V4L2 device index (0 = /dev/video0) |
camera |
width, height, fps |
Capture resolution and frame rate. Set width and height to 0 or "auto" to automatically detect and use the camera's maximum supported hardware resolution. |
camera |
night_mode |
Enable low-light enhancement / IR camera switching |
camera |
night_mode_strength |
Software enhancement strength: low, normal, or aggressive |
camera |
night_device |
Optional second V4L2 device for night vision (e.g. 1 for /dev/video1). Set to null to use a single camera. |
camera |
night_device_width, night_device_height, night_device_fps |
Resolution and FPS of the optional night camera (set width/height to 0 or "auto" for max resolution) |
camera |
night_software_enhance |
Apply CLAHE/gamma to the IR camera feed (true/false) |
camera |
night_mode_fps |
Target FPS in night mode (camera may ignore this) |
camera |
night_mode_gain |
Target analog gain in night mode (camera may ignore this) |
camera |
night_mode_brightness |
Brightness boost in night mode |
camera |
night_mode_contrast |
Contrast boost in night mode |
motion |
threshold |
Pixel difference threshold (0-255) |
motion |
min_area |
Minimum contour area to trigger motion (full-res pixels) |
motion |
blur_size |
Gaussian blur kernel size for noise reduction (odd number) |
motion |
cooldown_seconds |
Seconds to keep the motion flag active after last motion |
motion |
recording_tail_seconds |
Seconds to keep recording after last motion (joins door-open / person-enter bursts into one clip) |
motion |
scale |
Downscale factor for motion detection (saves CPU) |
recording |
dir |
Where videos are saved |
recording |
max_duration_seconds |
Maximum length of one clip |
recording |
pre_buffer_seconds |
Seconds before motion included in clip |
recording |
codec |
Preferred FourCC codec (MJPG for low CPU, mp4v for smaller files) |
notifications |
enabled |
Send email alerts on motion |
notifications |
smtp |
SMTP server, port, username, password, TLS |
notifications |
from, to, subject |
Email sender/recipients/subject (use ["a@...", "b@..."] for multiple recipients) |
notifications |
min_interval_minutes |
Minimum time between alert emails |
telegram |
enabled |
Send videos to Telegram after motion is recorded |
telegram |
bot_token, chat_id |
Telegram Bot API token and destination chat |
telegram |
send_video |
Whether to upload the video file automatically |
telegram |
min_interval_minutes |
Minimum time between Telegram uploads |
telegram |
poll_commands |
Enable /snapshot, /video, and /help bot commands |
cloud |
google_drive |
Google Drive auto-upload configuration (enabled, client_id, client_secret, refresh_token, optional folder_id) |
cloud |
onedrive |
Microsoft OneDrive auto-upload configuration (enabled, client_id, client_secret, refresh_token, folder_path) |
encryption |
passphrase |
Secret passphrase used to encrypt ZIP archives (AES-256) |
encryption |
telegram |
Enable AES-256 ZIP encryption for Telegram uploads & snapshots (true/false) |
encryption |
google_drive |
Enable AES-256 ZIP encryption for Google Drive uploads (true/false) |
encryption |
onedrive |
Enable AES-256 ZIP encryption for OneDrive uploads (true/false) |
storage |
max_age_days |
Delete recordings older than this (default 3 days = 72h) |
storage |
max_size_gb |
Delete oldest files if total exceeds this |
storage |
cleanup_interval_minutes |
How often storage cleanup runs |
storage |
delete_old_on_startup |
If false, old recordings are kept when the app restarts |
storage |
emergency_free_space_gb |
If free disk space drops below this, delete old recordings before a new one |
storage |
emergency_delete_count |
How many oldest recordings to delete in an emergency cleanup |
rtsp |
enabled |
Publish an RTSP stream in addition to the HTTP MJPEG stream |
rtsp |
port |
RTSP listener port (default 8554) |
rtsp |
path |
RTSP path (default live) |
rtsp |
width, height, fps |
Resolution and frame rate for the RTSP stream |
rtsp |
mediamtx_binary |
Path to the MediaMTX executable |
rtsp |
mediamtx_config |
Path to rtsp/mediamtx.yml |
web |
host, port |
Dashboard bind address and port |
web |
stream_scale |
Downscale factor for live stream (saves bandwidth/CPU) |
web.auth |
enabled, username, password |
Optional HTTP Basic Auth |
By default all credentials live in config.json. The file is created with restrictive (0o600) permissions, but you can keep the actual secrets off disk by supplying them through environment variables. Environment values take precedence over the config file and are never written back to disk.
| Secret | Environment variable |
|---|---|
| Telegram bot token | CHEAPSECURITY_TELEGRAM_BOT_TOKEN |
| Telegram chat ID | CHEAPSECURITY_TELEGRAM_CHAT_ID |
| SMTP password | CHEAPSECURITY_SMTP_PASSWORD |
| Google Drive client ID | CHEAPSECURITY_GDRIVE_CLIENT_ID |
| Google Drive client secret | CHEAPSECURITY_GDRIVE_CLIENT_SECRET |
| Google Drive refresh token | CHEAPSECURITY_GDRIVE_REFRESH_TOKEN |
| OneDrive client ID | CHEAPSECURITY_ONEDRIVE_CLIENT_ID |
| OneDrive client secret | CHEAPSECURITY_ONEDRIVE_CLIENT_SECRET |
| OneDrive refresh token | CHEAPSECURITY_ONEDRIVE_REFRESH_TOKEN |
| ZIP encryption passphrase | CHEAPSECURITY_ENCRYPTION_PASSPHRASE |
| Web dashboard password | CHEAPSECURITY_WEB_AUTH_PASSWORD |
Example with a systemd unit:
[Service]
Environment="CHEAPSECURITY_TELEGRAM_BOT_TOKEN=123456:ABC..."
Environment="CHEAPSECURITY_ENCRYPTION_PASSPHRASE=your-strong-passphrase"
EnvironmentFile=-/etc/cheapsecurity.envEven with secrets in the environment, the backend still runs as a normal user. You can sandbox it so a compromised process has very limited filesystem access:
[Service]
User=cheapsecurity
Group=cheapsecurity
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/home/cheapsecurity/CheapSecurity/recordings
BindReadOnlyPaths=/home/cheapsecurity/CheapSecurity/config.jsonThis confines the app to its recordings directory and read-only access to config.json. Note: if the attacker gains root or the service-user account, they can still read anything that user can read, including environment variables and credential files. For strongest protection, run behind an HTTPS reverse proxy on a trusted LAN and keep the OS up to date.
- Open Telegram and message @BotFather.
- Send
/newbotand follow the prompts to choose a display name and username. - Copy the bot token (looks like
123456789:ABCdefGHIjklMNOpqrsTUVwxyz). - Keep this token secret — anyone with it can control your bot.
- Start a private chat with your new bot and send any message (for example,
/start). - Open this URL in a browser, replacing
<YOUR_BOT_TOKEN>with the real token:https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates - Look for
"chat":{"id":123456789. The number is your chat ID.- If
getUpdatesis empty, send another message to the bot and refresh. - If you want to use a group chat, add the bot to the group first and send a message there; the chat ID will be negative for groups.
- If
- Copy the chat ID exactly, including the
-sign if it is a group.
Fill in the telegram section of config.json:
"telegram": {
"enabled": true,
"bot_token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"chat_id": "123456789",
"send_video": true,
"min_interval_minutes": 5,
"poll_commands": true
}Then restart the service:
sudo systemctl restart cheapsecurity@$(whoami).serviceAfter a motion clip is saved, the video is uploaded to your Telegram chat. Uploads are rate-limited by min_interval_minutes.
From your configured chat, send:
/snapshot— receive the current camera picture/video 10— record and send a 10-second video (1–60 seconds, default 10)/sent— list recent bot messages that can be deleted, with their message IDs/delete <message_id>— delete a bot message from the chat/delete last— delete the most recent bot message/delete_range <min_id> <max_id>— delete tracked messages in a range (max 100)/id— reply to any bot message with this command to see its message ID/telegram_on//telegram_off— enable or disable automatic Telegram uploads/email_on//email_off— enable or disable email notifications/help— list commands
The bot only responds to your configured chat_id.
Motion has priority: if the system is already recording because motion was detected, a /video request will not interrupt it. The bot will reply that a motion video is in progress and will be uploaded automatically.
CheapSecurity allows you to encrypt video clips and snapshots before uploading them to public channels (Telegram, Google Drive, or OneDrive).
Files are packaged into standard password-protected .zip archives using AES-256 encryption.
| Channel | Encryption ON (Passphrase set) | Encryption OFF |
|---|---|---|
Telegram Snapshots (/snapshot) |
Sent as AES-256 .zip file (snapshot_TIMESTAMP.zip) |
Sent as normal .jpg photo |
Telegram Videos (/video & motion) |
Sent as AES-256 .zip file (motion_TIMESTAMP.zip) |
Sent as normal .mp4/.avi video |
| Google Drive Uploads | Uploaded as AES-256 .zip file |
Uploaded as normal .avi video |
| OneDrive Uploads | Uploaded as AES-256 .zip file |
Uploaded as normal .avi video |
| Local Odroid Footage | Saved unencrypted on Odroid for fast dashboard playback | Saved unencrypted on Odroid for fast dashboard playback |
When encryption is enabled, downloaded files are standard password-protected .zip archives. No extra software or web apps are required!
- iPhone (iOS): Tap the
.zipfile in Telegram or the iOS Files app, enter your passphrase when prompted, and tap the unzipped.mp4or.jpgto view natively. - Android: Tap the
.zipfile in Files by Google or your file manager, enter your passphrase, and extract to view. - Mac / Windows / Linux: Double-click the
.zipfile (or use 7-Zip, WinRAR, Keka, or macOS Archive Utility), enter your passphrase, and play the unencrypted video.
You can check and toggle upload encryption on the fly using Telegram bot commands:
/encryption— Show current passphrase status and channel encryption state./encrypt_telegram_on//encrypt_telegram_off— Toggle Telegram AES-256 encryption./encrypt_gdrive_on//encrypt_gdrive_off— Toggle Google Drive AES-256 encryption./encrypt_onedrive_on//encrypt_onedrive_off— Toggle OneDrive AES-256 encryption.
Configure the notifications section in config.json. A picture from the moment motion starts is attached. Alerts are rate-limited by min_interval_minutes.
Google no longer allows "less secure apps" to use your regular Gmail password. You must create an App Password.
- Enable 2-Step Verification on your Google account:
- Create an App Password:
- Go to https://myaccount.google.com/apppasswords
- Select app: Mail
- Select device: Other (Custom name) — type "CheapSecurity"
- Click Generate and copy the 16-character password (for example,
abcd efgh ijkl mnop).
- In
config.json, set:"notifications": { "enabled": true, "smtp": { "server": "smtp.gmail.com", "port": 465, "username": "you@gmail.com", "password": "abcdefghijklmnop", "use_tls": true }, "from": "you@gmail.com", "to": "you@gmail.com", "subject": "CheapSecurity motion alert", "min_interval_minutes": 5 }
- Use the App Password (no spaces) in the
passwordfield, not your Google account password. - For Google Workspace accounts, the username is usually your full email address.
- The app uses implicit TLS (
SMTP_SSL) on the port you configure. Gmail accepts this on port 465.
- Use the App Password (no spaces) in the
"to": [
"you@gmail.com",
"family@example.com"
]Night mode can work in two ways:
The default mode improves a dark scene from one USB camera using:
- Software enhancement (gamma correction + CLAHE on the L channel)
- Camera brightness/contrast boost
- Attempts to lower FPS and raise gain/ISO if the camera supports it
The strength of the software enhancement is selectable from the dashboard or Telegram: low, normal, or aggressive.
Important: most USB webcams do not expose ISO/gain/exposure controls via V4L2, so FPS/gain adjustments may be ignored. The result is usually noisy and only useful with some ambient light.
For real night vision you can connect a second camera such as an IR-sensitive USB camera or a camera with an IR cut filter removed, optionally with an IR illuminator. When camera.night_device is set, enabling night mode automatically switches the video source from the day camera to the IR camera; disabling it switches back. Only one camera is open at a time, so USB bandwidth is not doubled.
Example configuration:
"camera": {
"device": 0,
"width": 2560,
"height": 1440,
"fps": 15,
"night_mode": false,
"night_mode_strength": "low",
"night_device": 1,
"night_device_width": 1280,
"night_device_height": 720,
"night_device_fps": 15,
"night_software_enhance": false
}night_device— V4L2 device index of the IR camera (e.g.1for/dev/video1). Set tonullfor single-camera mode.night_device_width/height/fps— resolution and frame rate of the IR camera.night_software_enhance— set tofalseif the IR image is already usable; set totrueif you want the CLAHE/gamma enhancement applied to the IR feed as well.
If the IR camera fails to open, the system falls back to the day camera and logs a warning.
You can use cron on the Odroid (or any Linux host) to switch between day and IR cameras automatically. The dashboard's REST API accepts X-Requested-With: XMLHttpRequest as a CSRF-safe header, so a simple curl command is enough.
Without auth:
# Switch to IR/night camera at 22:00
curl -s -X POST http://192.168.178.41:5000/api/settings/night_mode \
-H "Content-Type: application/json" \
-H "X-Requested-With: XMLHttpRequest" \
-d '{"enabled": true, "strength": "low"}'
# Switch back to day camera at 06:30
curl -s -X POST http://192.168.178.41:5000/api/settings/night_mode \
-H "Content-Type: application/json" \
-H "X-Requested-With: XMLHttpRequest" \
-d '{"enabled": false}'With Basic Auth:
curl -s -X POST http://192.168.178.41:5000/api/settings/night_mode \
-u admin:YOUR_PASSWORD \
-H "Content-Type: application/json" \
-H "X-Requested-With: XMLHttpRequest" \
-d '{"enabled": true, "strength": "low"}'Add to your crontab (crontab -e):
# Night (IR) camera at 22:00
0 22 * * * curl -s -X POST http://192.168.178.41:5000/api/settings/night_mode -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" -d '{"enabled": true, "strength": "low"}' > /dev/null 2>&1
# Day camera at 06:30
30 6 * * * curl -s -X POST http://192.168.178.41:5000/api/settings/night_mode -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" -d '{"enabled": false}' > /dev/null 2>&1Tip: If you enabled Basic Auth, put the credentials in an environment variable or a small wrapper script with
0600permissions instead of pasting them directly into the crontab.
- Recordings are saved in
recordings/. - Recordings older than
max_age_daysare deleted during periodic cleanup, not on startup (unlessdelete_old_on_startupistrue). - If free disk space drops below
emergency_free_space_gb, the oldestemergency_delete_countrecordings are deleted before starting a new clip. - Recordings older than
max_age_daysor exceedingmax_size_gbare removed during periodic cleanup.
CheapSecurity exposes a small REST API and serves an interactive Swagger UI at:
http://<odroid-ip>:5000/api/
The Swagger page documents every endpoint and shows example request/response bodies. You can try the endpoints directly from the browser.
If web.auth.enabled is true, the API uses HTTP Basic Auth. In Swagger, click Authorize and enter your username/password. From scripts, include the credentials with curl:
curl -u admin:changeme http://<odroid-ip>:5000/api/statusThe web dashboard protects POST endpoints with a CSRF check (the X-Requested-With: XMLHttpRequest header). Requests made from the Swagger UI page are automatically allowed. From your own scripts, add the header:
curl -u admin:changeme \
-H "X-Requested-With: XMLHttpRequest" \
-X POST http://<odroid-ip>:5000/api/settings/night_mode \
-H "Content-Type: application/json" \
-d '{"enabled": true}'GET /api/status— current engine stateGET /api/recordings— list saved videosPOST /api/recordings/delete— delete selected recordingsPOST /api/recordings/download— download selected recordings as ZIPPOST /api/recordings/telegram— send selected recordings to TelegramPOST /api/telegram/delete— delete a previously sent Telegram message by IDPOST /api/telegram/delete_range— delete tracked Telegram messages in an ID rangePOST /api/settings/{telegram,night_mode,notifications,auth}— toggle featuresPOST /api/snapshot— capture and download a JPEG snapshotPOST /api/video— start a manual recording (default 10s, max 60s)
curl -u admin:changeme \
-H "X-Requested-With: XMLHttpRequest" \
-X POST http://<odroid-ip>:5000/api/snapshot \
--output snapshot.jpgcurl -u admin:changeme \
-H "X-Requested-With: XMLHttpRequest" \
-X POST http://<odroid-ip>:5000/api/video \
-H "Content-Type: application/json" \
-d '{"seconds": 10}'- Live stream
- Status panel (resolution, FPS, recording state, motion state)
- Settings toggles: night mode, email notifications, Telegram uploads, built-in basic auth
- Recordings list with per-row checkboxes and bulk actions:
- Select all
- Send to Telegram
- Download selected (ZIP)
- Delete selected
- Link to the Swagger API docs at
/api/
CheapSecurity can republish the live MJPEG stream as an RTSP stream. This lets you view the camera in VLC, IP-camera apps, NVRs, or any software that supports RTSP, without using the web dashboard.
RTSP is disabled by default because it adds extra CPU load.
- Install FFmpeg and MediaMTX (see the options below).
- Make sure
rtsp/mediamtx.ymlexists in the project folder. - Set
"rtsp": { "enabled": true, ... }inconfig.json. - Restart the service:
sudo systemctl restart cheapsecurity@$(whoami).service - Open
rtsp://<odroid-ip>:8554/livein your RTSP player.
The subsections below explain each step in detail.
- CheapSecurity starts a local MediaMTX server on the configured RTSP port.
- It launches FFmpeg to read the HTTP MJPEG stream from
http://127.0.0.1:5000/video_feed. - FFmpeg republishes the stream to MediaMTX on
rtsp://127.0.0.1:<port>/<path>. - Any RTSP client on your network can connect to
rtsp://<odroid-ip>:<port>/<path>.
You need FFmpeg and MediaMTX.
Note: The commands below use
sudo. If your board's root access is viasuinstead, runsufirst and execute the commands withoutsudo.
- FFmpeg must be installed (
ffmpeg -version). It is also used for the video-duration fix. - MediaMTX can be installed in several ways:
Download the static binary that matches your board from https://github.com/bluenviron/mediamtx/releases. For an Odroid XU4 (ARMv7) choose the ARMv7 build.
wget https://github.com/bluenviron/mediamtx/releases/download/v1.12.0/mediamtx_v1.12.0_linux_armv7.tar.gz
tar -xzf mediamtx_v1.12.0_linux_armv7.tar.gz
sudo install -m 755 mediamtx /usr/local/bin/mediamtxReplace
v1.12.0with the latest release.
Then copy the bundled config file into the project:
mkdir -p rtsp
cp mediamtx.yml rtsp/mediamtx.ymlThe repository already contains a minimal rtsp/mediamtx.yml that works with the default settings; you only need to copy the one from the MediaMTX archive if you want the upstream defaults.
If you prefer to build from source, install Go (≥ 1.26) and build directly on the board:
# Install Go from your distribution or from https://go.dev/dl/
sudo apt update
sudo apt install golang-go git
# Clone and build
git clone https://github.com/bluenviron/mediamtx /tmp/mediamtx
cd /tmp/mediamtx
go generate ./...
CGO_ENABLED=0 go build .
# Install the binary
sudo install -m 755 mediamtx /usr/local/bin/mediamtxThis produces a native ARMv7 mediamtx binary.
If you want to compile on a faster x86/amd64 machine and copy the binary to the Odroid, use Go cross-compilation:
git clone https://github.com/bluenviron/mediamtx
cd mediamtx
go generate ./...
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build .Then copy the resulting mediamtx binary to the Odroid, for example:
scp mediamtx marco@<odroid-ip>:/tmp/mediamtx
ssh marco@<odroid-ip> "sudo install -m 755 /tmp/mediamtx /usr/local/bin/mediamtx"On the Odroid, also copy the config file:
mkdir -p rtsp
cp mediamtx.yml rtsp/mediamtx.yml"rtsp": {
"enabled": true,
"port": 8554,
"path": "live",
"width": 1280,
"height": 720,
"fps": 15,
"mediamtx_binary": "/usr/local/bin/mediamtx",
"mediamtx_config": "rtsp/mediamtx.yml"
}The bundled rtsp/mediamtx.yml forces RTSP over TCP (rtspTransports: [tcp]), which is more reliable through routers and firewalls than UDP. If you prefer UDP on your local network, edit rtsp/mediamtx.yml and change it to [udp, tcp] or [udp].
Adjust width, height, and fps to match your board’s CPU. Lower resolution and FPS reduce load.
sudo systemctl restart cheapsecurity@$(whoami).serviceThe stream URL is:
rtsp://<odroid-ip>:8554/live
Examples:
- VLC → Media → Open Network Stream → paste
rtsp://<odroid-ip>:8554/live - ffplay:
ffplay -rtsp_transport tcp rtsp://<odroid-ip>:8554/live
The
-rtsp_transport tcpflag avoids UDP RTP issues through routers/firewalls. VLC has a similar--rtsp-tcpoption. - Android IP Camera apps — add a camera with the RTSP URL above.
- NVR / Home Assistant — use the same RTSP URL as the camera source.
Look for MediaMTX and FFmpeg in the logs:
sudo journalctl -u cheapsecurity@$(whoami).service -fYou should see messages like:
RTSPPublisher: MediaMTX ready on port 8554
RTSPPublisher: FFmpeg publisher started
- RTSP is republished from the HTTP MJPEG stream, so it uses extra CPU. On the Odroid XU4, 1280×720 @ 15fps is a good starting point.
- When HTTP Basic Auth is enabled, FFmpeg is allowed to read
/video_feedfromlocalhostwithout credentials, so RTSP still works. - Make sure port
8554/tcpis open in your firewall if you view it from another machine. - If the RTSP stream does not appear, verify:
- MediaMTX binary exists and is executable.
rtsp/mediamtx.ymlexists.- FFmpeg is installed.
- The dashboard stream at
http://<odroid-ip>:5000/video_feedworks.
- If the client connects but shows a black screen / no video, the player probably chose UDP and the RTP packets are being blocked. Force TCP on the client (
-rtsp_transport tcpin ffplay,--rtsp-tcpin VLC) or keeprtspTransports: [tcp]inrtsp/mediamtx.ymland restart the service.
Do not expose Flask's development server to the internet. Use Gunicorn behind the built-in auth or another reverse proxy you trust.
It is already defined in pyproject.toml:
cd $HOME/CheapSecurity
source venv/bin/activate
pip install -e .Copy the service template and enable it from a user shell:
sudo cp cheapsecurity.service /etc/systemd/system/cheapsecurity@.service
sudo systemctl daemon-reload
sudo systemctl enable --now cheapsecurity@$(whoami).serviceThis binds Gunicorn to 0.0.0.0:5000 with one worker and four threads, so the dashboard and stream are reachable directly on your network. Only one worker is used because the camera must be opened by a single process.
Security: if you expose this to the internet, put a reverse proxy with HTTPS and authentication in front of Gunicorn. If you only access it locally, keep the built-in auth enabled.
View logs:
sudo journalctl -u cheapsecurity@$(whoami).service -fCheapSecurity/
├── src/
│ └── cheapsecurity/ # Python package
│ ├── app.py # Development launcher
│ ├── cctv.py # Motion detection, recording, alerts, Telegram bot
│ ├── web.py # Flask dashboard and APIs
│ ├── wsgi.py # Production WSGI entry point
│ ├── diagnose.py # Diagnostic/troubleshooting script
│ ├── templates/ # HTML templates
│ └── static/ # CSS/JS
├── tests/ # Test suite
├── config.json # Your local settings (gitignored, never commit)
├── config.json.example # Example settings template (committed)
├── pyproject.toml # Package metadata and dependencies
├── cheapsecurity.service # systemd template
├── LICENSE # GNU AGPLv3
└── recordings/ # Saved videos
If recordings stop appearing:
- Check the service is running:
sudo systemctl status cheapsecurity@$(whoami).service - Check logs:
sudo journalctl -u cheapsecurity@$(whoami).service -f - Run the diagnostic script:
source venv/bin/activate python -m cheapsecurity.diagnose - Try lowering
motion.min_areaif no motion is detected.
This software is developed with strong assistance from Kimi 2.7 and Gemini 3.6 flash and with humans leading the ideas, testing, and debugging. We say this openly because it shaped how the project was built. If you are not happy with AI-developed code, this software is not for you.
This project is licensed under the GNU Affero General Public License v3.0 or later (AGPLv3). See LICENSE.
