Know what moved without scrubbing through hours of footage.
A self-hosted ESP32-CAM recorder that produces a live health overlay, searchable motion clips, and scheduled Discord summaries.
CCTV accepts an MJPEG stream and control telemetry from a single ESP32-CAM. It adds an operational HUD, detects sustained motion, records camera-timed video segments, and indexes both recordings and events. A self-hoster receives a live browser view, time-based footage retrieval, motion-specific clips, statistics, and optional Discord digests—useful outputs when the important question is what happened and when.
The operator interface is a responsive, video-first HTML, CSS, and JavaScript dashboard served by FastAPI. A Swift worker handles the latency-sensitive path with URLSession, Core Image, Metal, VideoToolbox, and Vision; Python supervises that worker, persists metadata with SQLite and SQLAlchemy, serves media, and runs scheduled jobs. Native events cross a versioned JSON pipe, while an OpenCV implementation remains available as an automatic fallback.
| Area | What the project provides |
|---|---|
| Camera lifecycle | Polls the ESP32 status endpoints, dynamically selects framesize 11 or 12 from sustained scene brightness, synchronizes its clock, reapplies AWB/exposure/AGC policy after reconnects, and exposes OTA recovery state through Redis when available. |
| Native capture | Parses multipart MJPEG with a bounded latest-frame queue, follows camera arrival timestamps, and uses hardware JPEG processing and VideoToolbox encoders. The recorded Apple M4 canary reduced median capture-tree CPU by 65.6% and representative segment size by 69.3% versus the Python path. |
| Live observability | Composites timestamp, measured FPS, Wi-Fi RSSI, SoC temperature, motion state, and person/animal labels into the outgoing frame. Telemetry failures leave the feed running with unavailable values. |
| Signal recovery | Detects a three-second JPEG stall, keeps archive and RTSP outputs alive with a NO SIGNAL · RECONNECTING frame, retries the stream, and coalesces disconnects into one camera startup sequence. |
| Recording and retention | Writes atomically finalized, camera-timed HEVC MP4 segments; registers them in a SQLite catalog; reconciles the catalog at startup; and prunes older footage from 90% disk use toward 85% without deleting recent or partial files. |
| Motion intelligence | Uses ROI-aware VideoToolbox motion vectors, temporal persistence, and global-lighting rejection. Vision classification runs only for motion candidates and adds person or animal annotations without changing the base event schema. |
| Review and retrieval | Serves a dashboard plus APIs for recording lists, exact timestamps, arbitrary ranges, hourly/day windows, motion queries, hourly statistics, and accurately trimmed H.264 event clips with configurable pre/post padding. |
| Scheduled reporting | Merges nearby overnight events, downloads their clips from the API, compresses them under the configured Discord transport limit, and sends a summary and videos through a retrying gRPC client. An optional OpenAI job produces a daily text summary and plots. |
| Operational resilience | Restarts a failed capture process, latches to the Python/OpenCV fallback after three native failures in five minutes, publishes health metrics every ten seconds, and includes launchd definitions for capture, API, and the nightly job. |
Note
Native capture, the Python fallback, indexed recording, motion persistence, the FastAPI API, the browser dashboard, and nightly clip delivery are implemented. The system currently targets one indoor camera; vehicle classification is intentionally disabled. The RTSP server and Discord webhook gRPC server are required external services and are not included here. The AI daily summary is optional and requires an OpenAI API key.
flowchart LR
A["ESP32-CAM MJPEG"] --> B["Camera startup and capture"]
B --> C["Decode, motion analysis, and HUD"]
C --> D["HEVC segments"]
C --> E["H.264 RTSP stream"]
C --> F["Versioned events"]
D --> G["Recording catalog"]
F --> H["Motion database"]
G --> I["FastAPI and dashboard"]
H --> I
E --> J["Live browser view"]
I --> K["Trimmed event clips"]
K --> L["Nightly Discord digest"]
C --> M{"JPEG stalled?"}
M -- "Yes" --> N["No-signal keepalive"]
N --> O["Reconnect and retune"]
O --> B
Fresh JPEGs are processed at their measured arrival cadence; CCTV_TARGET_FPS is an encoder hint and the no-signal keepalive rate rather than a fixed live-frame scheduler. A stalled stream is retried while recording continues. Blocking FFmpeg work is moved off FastAPI's async event loop, generated clips are reused until hourly cleanup, and transient Discord RPC failures receive up to three attempts.
flowchart TD
CCTV["CCTV"]
CCTV --> Live["Live awareness"]
CCTV --> Evidence["Recorded evidence"]
CCTV --> Activity["Activity history"]
CCTV --> Reports["Scheduled reports"]
Live --> Overlay["Health-overlay stream"]
Live --> Status["Server and camera status"]
Evidence --> Segments["HEVC recording segments"]
Evidence --> Clips["Padded H.264 event clips"]
Activity --> Events["Motion events and labels"]
Activity --> Stats["Range and hourly statistics"]
Reports --> Night["Overnight summary and clips"]
Reports --> Daily["Optional AI summary and plots"]
flowchart LR
subgraph Edge["Camera and network edge"]
Camera["ESP32-CAM\nMJPEG and control endpoints"]
RTSP["External RTSP server"]
Discord["External Discord gRPC bridge"]
Redis["Optional Redis"]
end
subgraph Capture["Capture and domain services"]
Orchestrator["Python orchestrator"]
Native["Swift capture worker"]
Fallback["Python/OpenCV fallback"]
Digest["Nightly and daily jobs"]
end
subgraph Data["Persistence and media"]
Recordings[("HEVC MP4 segments")]
Catalog[("SQLite recording catalog")]
Motion[("SQLite motion events")]
Temp[("Temporary H.264 clips")]
end
subgraph Presentation["Presentation"]
API["FastAPI service"]
Dashboard["Browser dashboard"]
end
Orchestrator --> Native
Orchestrator -. "failure fallback" .-> Fallback
Camera --> Native
Camera --> Fallback
Native --> Recordings
Native --> RTSP
Native -- "JSON events" --> Orchestrator
Orchestrator --> Catalog
Orchestrator --> Motion
Orchestrator --> Redis
Catalog --> API
Motion --> API
Recordings --> API
API --> Temp
API --> Dashboard
RTSP --> Dashboard
API --> Digest
Digest --> Discord
Python owns process supervision, camera recovery, storage policy, SQLite writes, and API/scheduled work; Swift owns frame acquisition, analysis, composition, and hardware encoding. The native worker emits additive, versioned event envelopes through a dedicated file descriptor so persistence stays outside the frame path. Actor-isolated runtime state and candidate-only Vision work prevent slow classification from backing up capture. The API queries indexed metadata instead of rescanning recordings and offloads video concatenation and transcoding to worker threads.
| Layer | Technology |
|---|---|
| Languages | Python 3.12+, Swift 6.2, JavaScript, HTML, and CSS |
| Native capture | URLSession, Core Image, Metal, Core Video, AVFoundation, and VideoToolbox |
| Detection | VideoToolbox motion estimation and Apple Vision person/image classification |
| Fallback capture | OpenCV and NumPy |
| Backend | FastAPI 0.124.4 and Uvicorn |
| Persistence | SQLite, SQLAlchemy 2.0.46, and a direct sqlite3 recording catalog |
| Video tooling | FFmpeg/ffprobe, HEVC archives, H.264 RTSP, and h264_videotoolbox clip encoding |
| Messaging | gRPC 1.71, Protocol Buffers, and an external Discord webhook bridge |
| Optional insights | OpenAI Responses API and Matplotlib |
| Operations | macOS launchd, shell launchers, health events, and benchmark scripts |
| Testing | XCTest and Python unittest |
.
├── native/
│ ├── Sources/CCTVCapture/ # Swift capture, detection, HUD, and encoders
│ ├── Tests/CCTVCaptureTests/ # Native configuration and pipeline tests
│ └── Package.swift # Swift 6.2 package and macOS 26 target
├── image_processing/
│ ├── pipeline_orchestrator.py # Supervision, recovery, catalog, and retention
│ └── camera_pipeline.py # Python/OpenCV fallback pipeline
├── server/
│ ├── server.py # FastAPI dashboard, motion, and video routes
│ └── static/ # Video-first browser dashboard
├── motion/ # Nightly clips, daily plots, and AI summary jobs
├── utilities/ # Camera client, SQLite models, and catalog helpers
├── discord_grpc/ # Generated stubs and retrying Discord client
├── proto/discord_webhook.proto # Discord bridge contract
├── tools/ # Camera controls, diagnostics, and benchmarks
├── tests/ # Python event-protocol and catalog tests
├── launchd/ # Machine-specific service definitions
├── benchmarks/ # Python baseline and native canary evidence
├── scripts/build-native.sh # Release build for the Swift worker
├── requirements.txt # Pinned Python dependencies
└── LICENSE # GNU GPLv3 text
- An Apple Silicon Mac running macOS 26. The native package declares macOS 26 as its minimum platform.
- The full Xcode toolchain with Swift 6.2 or later.
scripts/build-native.shexpects Xcode at/Applications/Xcode.appunlessDEVELOPER_DIRis overridden. - Python 3.12 and a virtual environment.
- FFmpeg and ffprobe with the
h264_videotoolboxencoder available. - One ESP32-CAM reachable over the local network with MJPEG, control, status, RSSI, and system-health endpoints compatible with
utilities/esp32cam_client.py. - A writable recordings directory and a writable motion-data directory. Defaults point to
/Volumes/drive/CCTV/...; local paths can be supplied through environment variables. - An RTSP service that accepts the worker's publisher URL. Browser playback also needs a browser-compatible live URL configured through
CCTV_LIVE_STREAM_URL. - For notifications, the separate Discord webhook gRPC service described by
proto/discord_webhook.proto. For the optional AI digest, anOPENAI_API_KEYis also required. - Redis is optional for ordinary capture but required for the ESP32 OTA recovery flag and
/esp32cam/recoverystatus route.
There is no camera simulator. Unit tests run without hardware, but live capture, recovery, RTSP publishing, telemetry, and end-to-end clip delivery require the physical camera and their respective local services. Manual development runs use shell configuration; the supplied launchd files are production-style examples with machine-specific absolute paths.
Warning
The FastAPI app binds to 0.0.0.0, allows CORS from every origin, and does not implement authentication. Keep it on a trusted LAN or place it behind an authenticated reverse proxy; do not expose port 8005 directly to the internet.
-
Clone the repository and enter it.
git clone https://github.com/aneeshpatne/CCTV.git cd CCTV -
Create the Python environment and install the pinned dependencies.
python3.12 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip python -m pip install -r requirements.txt -
Install FFmpeg and verify hardware H.264 encoding.
brew install ffmpeg ffmpeg -hide_banner -encoders | grep h264_videotoolbox -
Create local data directories and configure the camera, storage, and live endpoints for the current shell.
mkdir -p recordings/esp_cam1 motion/data export ESP32CAM_BASE_URL="http://192.168.0.13" export ESP32CAM_STREAM_URL="http://192.168.0.13:81/stream" export CCTV_RECORDINGS_DIR="$PWD/recordings/esp_cam1" export MOTION_DB_DIR="$CCTV_RECORDINGS_DIR" export MOTION_DATA_DIR="$PWD/motion/data" export CCTV_RTSP_URL="rtsp://127.0.0.1:8554/esp_cam1_overlay" export CCTV_LIVE_STREAM_URL="http://127.0.0.1:8889/esp_cam1_overlay/"
CCTV_PIPELINE_BACKENDacceptsnative,python, orauto; the default isnative, with fallback when the binary is missing or repeatedly fails. Native tuning is available throughCCTV_TARGET_FPS,CCTV_SEGMENT_SECONDS,CCTV_HEVC_BITRATE,CCTV_RTSP_BITRATE, andCCTV_POST_CONNECT_ADJUSTMENT_DELAY_SECONDS.Dynamic resolution uses full-frame BT.709 luminance before overlays. It selects framesize 11 below 25%, framesize 12 above 35%, holds the current setting inside that hysteresis band, observes for at least 30 seconds across a 60-second window, and starts a 15-minute cooldown only after the camera verifies the change. These defaults can be overridden with
CCTV_DIM_BRIGHTNESS_THRESHOLD,CCTV_BRIGHT_BRIGHTNESS_THRESHOLD,CCTV_BRIGHTNESS_OBSERVATION_SECONDS,CCTV_BRIGHTNESS_WINDOW_SECONDS, andCCTV_RESOLUTION_COOLDOWN_SECONDS. -
Build the native capture worker.
./scripts/build-native.sh
-
Start a compatible RTSP server, then run the orchestrator.
source .venv/bin/activate python -m image_processing.pipeline_orchestrator -
In a second terminal with the same exported paths, start the API and open
http://127.0.0.1:8005/.source .venv/bin/activate python -m server.server -
Optionally configure the external Discord bridge and run the overnight digest.
export DISCORD_GRPC_TARGET="127.0.0.1:50051" export DISCORD_CHANNEL="cctv" python -m motion.motion
The optional AI daily job additionally needs
OPENAI_API_KEYand runs withpython -m motion.night_message.
Important
The repository contains development defaults for camera IPs, recording volumes, RTSP/live stream URLs, Discord targets, and absolute paths in launchd/*.plist. Replace them for the target machine before installing services or distributing a configured copy. The external RTSP and Discord services must be started separately.
Once manual startup is verified, adapt the three property lists in launchd/, copy them to ~/Library/LaunchAgents/, and load only the services you use. Do not start a second capture process while the LaunchAgent is active: the configured ESP32 MJPEG stream is treated as single-consumer.
Run the Python suite from the repository root:
source .venv/bin/activate
python -m unittest discover -s tests -vRun the Swift suite from the native package:
cd native
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
CLANG_MODULE_CACHE_PATH=/tmp/cctv-clang-module-cache \
SWIFT_MODULE_CACHE_PATH=/tmp/cctv-swift-module-cache \
xcrun swift test --disable-sandboxIn an IDE, use Python unittest discovery for tests/, or open native/Package.swift in Xcode and choose Product → Test. The current tests cover native configuration, MJPEG parsing, variable-frame-rate timing, RTSP arguments, motion accumulation and event envelopes, plus Python-side event persistence and recording-catalog reconciliation. API, storage-pruning, hardware replay, and Discord integration are not currently covered by the committed automated suite.
- Consolidate camera addresses, storage paths, stream URLs, and service targets into one shared configuration surface instead of per-process environment lookups and development defaults.
- Generalize the single-camera orchestrator, catalog, and dashboard state into explicit per-camera configuration.
- Replace the machine-specific
launchdproperty lists with an installer or generated templates that resolve the repository and virtual-environment paths. - Add authenticated deployment guidance or application-level access control for installations that must be reachable beyond a trusted LAN.
- Extend automated coverage to API clip generation, storage cleanup, camera replay/golden HUD output, and a stub Discord gRPC integration.
CCTV is released under the GNU General Public License v3.0. In practical terms, redistributed covered versions must remain under GPLv3 and include the corresponding source and license notices; consult the license text for the complete terms.