Turn an old Android phone into a always-on NVR for a cheap RTSP/Wi-Fi camera, and watch the motion clips from a companion Android app — no cloud subscription.
Most consumer Wi-Fi cameras only let you scroll back a day or two unless you pay a monthly fee. This project records the same live RTSP stream yourself, keeps only the moments with motion (with a few seconds of pre-roll), archives them to Google Drive, and serves them to a phone app that plays each clip in place.
It runs on hardware you already own: the recorder is a spare Android phone running Termux (ffmpeg + bash), and the viewer is a small Kotlin / Jetpack Compose app that now also runs a small on-device object detector over each clip — labelling person / vehicle / animal with no extra hardware and no AI accelerator. A heavier Frigate path (real-time detection on a Raspberry Pi / mini-PC) remains an optional upgrade, not a requirement.
Full design rationale in
ARCHITECTURE.md. Where the project stands and what comes next:ROADMAP.md. Plans in progress:LOCAL_ARCHIVE_PLAN.md(shipped),MULTICAMERA_PLAN.md(draft, open questions).
termux/ The NVR: bash + ffmpeg scripts that run on the phone (or any Linux box)
consumer-app/ Android viewer app (Kotlin, Compose, Media3, Google Drive)
frigate/ Optional: Frigate NVR config for AI person/car detection on a Pi/mini-PC
.env.example Camera credential template (copy to .env, which is gitignored)
┌──────────────┐ RTSP 2K (ch0) ┌────────────────────┐ mp4 + jpg ┌──────────────┐
│ Wi-Fi camera│ ───────────────▶ │ Phone NVR (Termux)│ ─────────────▶ │ Google Drive │
│ (RTSP) │ RTSP 360p(ch1) │ ffmpeg + bash │ rclone sync │ │
└──────────────┘ ───────────────▶ └────────────────────┘ └──────┬───────┘
detect on 360p ring buffer + keeper │ Drive API
record on 2K pre-roll, exact trim ▼
┌──────────────────┐
│ Android viewer │
│ app (Compose) │
└──────────────────┘
- Segmenter records the 2K main stream continuously into a ring buffer (short segments, outside the uploaded folder).
- Detector watches the cheap 360p sub-stream for motion (frame-difference on a cropped ROI) — about ⅕ the CPU of decoding the 2K.
- Keeper takes the segments overlapping each motion window (so the pre-roll is already
captured), trims a tight clip by profiling the real motion on the 2K, writes a thumbnail and a
metrics row, and drops it into
Cameras/<cam>/YYYY/MM/DD/mt_*.mp4. - cloud-sync mirrors the tree to Google Drive with rclone and enforces local + cloud retention.
- A watchdog keeps everything alive across crashes and reboots.
- The app lists the clips straight from Drive (
drive.readonly), groups them by day, shows thumbnails / motion intensity / camera health, and streams each clip in-app.
See termux/README.md. In short:
cp termux/cam.env.example ~/cam1.env # edit with your RTSP URL (see .env.example)
# install ffmpeg/tmux/openssh, then:
tmux new-session -d -s cam1 "cd ~ && CAM_ENV=~/cam1.env exec ./record-preroll.sh"
tmux new-session -d -s cloud "cd ~ && exec ./cloud-sync.sh"Open consumer-app/ in Android Studio and Sync. You'll need a Google OAuth
client (Android) with the Drive API enabled — steps in
consumer-app/README.md. The app UI supports English and Spanish
(switchable in-app).
See frigate/: point it at the same RTSP URLs and run docker compose up -d.
- Pre-roll without wasting CPU — record the 2K, detect on the 360p; a ring buffer means every clip includes the seconds before the trigger with ~0 latency.
- Content-based tail trimming — clips end a couple of seconds after the last real motion measured on the 2K, not on a fixed timer, so there's no dead footage.
- Atomic writes — clips/thumbnails are written to
.partand renamed, so the uploader never ships a half-written file. - On-device detection (no extra hardware) — a small TFLite object detector runs over each clip's thumbnail (which the app already downloads) and labels it person / vehicle / animal / none. That drives a 👤 badge on the card, a "People only" filter, and a people-only alert gate — so the gallery of mostly shadows/insects/rain becomes the handful of clips that are a person. It never decides what gets recorded, only what gets announced/shown, and fails open (no model → all alerts pass). A one-tap backfill labels the existing history too.
- Self-healing — a watchdog revives the recorder, uploader, wake-lock and sshd; a boot script restarts everything after a reboot; camera "stuck" (wedged) states are classified and surfaced with staggered backoff; a blind detector is auto-restarted, and if the RTSP detector stays down the NVR falls back to scanning the recording ring so motion is still caught during the outage.
- Health surfacing & observability — the NVR writes
status.json(recording fresh? battery? charging? recording in 2K or 360p? detector alive? camera wedged? disk low? Wi-Fi RSSI?) which the app turns into banners and notifications. A Salud screen adds a per-service coverage timeline (1h → 30d zoom) reconstructed from anevents.jsonloutage log, plus a Wi-Fi signal trend chart from a densewifi.jsonlseries — so a weak link can be correlated with the drops it causes. - More than a viewer — the app also does a live RTSP view (SD/2K, fullscreen, works remotely over Tailscale), live snapshots, photo alerts that open the live view, presence-based Home/Away alerting (manual or by location), offline downloads, a storage breakdown, favorites, and per-camera battery forecasts.
- The camera is only reachable on the LAN, so the recorder must run on the same network.
- Credentials live only in a local
.env/cam.env(gitignored) — see.env.example. - The pipeline is plain RTSP, so it works with any camera that exposes a main + sub stream.
The specific camera this project was built and validated against is a FAMVIVA 2K (AJCloud
platform) Wi-Fi camera — its two streams are
ch0(2K, recording) andch1(360p, detection).
MIT — see LICENSE.