From 3a6a79bf680cca797e80fd6869769ad8895541be Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 5 Apr 2026 13:16:22 -0400 Subject: [PATCH] docs(plans): camera stream simulator implementation plan Task-by-task plan for the dev-only RTSP simulator: gitignore entries, MediaMTX downloader, syntax-check test guard, the sim script itself, and a human acceptance walk-through. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-camera-stream-simulator.md | 458 ++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-camera-stream-simulator.md diff --git a/docs/superpowers/plans/2026-04-05-camera-stream-simulator.md b/docs/superpowers/plans/2026-04-05-camera-stream-simulator.md new file mode 100644 index 0000000..35b2897 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-camera-stream-simulator.md @@ -0,0 +1,458 @@ +# Camera Stream Simulator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add dev tooling that loops phone-recorded mp4 files as local RTSP streams so Vigilar's real camera pipeline can run end-to-end without physical cameras. + +**Architecture:** One bash script (`scripts/sim_cameras.sh`) starts MediaMTX on `127.0.0.1:8554` and spawns one `ffmpeg -stream_loop -1 -re` per camera defined in `config/vigilar.toml`, publishing each video as `rtsp://127.0.0.1:8554/`. A generated `config/vigilar.sim.toml` rewrites camera URLs to the local endpoints. A second script downloads the MediaMTX static binary on demand. The real config is never mutated. + +**Tech Stack:** bash, ffmpeg, MediaMTX (static Go binary), pytest (for the syntax-check guard). + +**Spec:** `docs/superpowers/specs/2026-04-05-camera-stream-simulator-design.md` + +--- + +## File Inventory + +**Create:** +- `scripts/sim_cameras.sh` — main simulator script +- `scripts/download_mediamtx.sh` — one-time MediaMTX binary installer +- `tests/unit/test_sim_script.py` — syntax check + optional shellcheck + +**Modify:** +- `.gitignore` — add `videos/`, `.sim/`, `config/vigilar.sim.toml` + +**Not touched:** Any file under `vigilar/`. The simulator is fully external to the package. + +--- + +## Task 1: .gitignore entries + +**Files:** +- Modify: `.gitignore` + +- [ ] **Step 1: Append simulator entries** + +Append to `.gitignore` (before the final "WireGuard keys" block or at the very end — order doesn't matter): + +``` +# Camera stream simulator (dev tooling) +videos/ +.sim/ +config/vigilar.sim.toml +``` + +- [ ] **Step 2: Verify** + +Run: `git check-ignore -v videos/ .sim/ config/vigilar.sim.toml` +Expected: each path prints a match against the new lines. + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "chore(gitignore): ignore camera simulator working files" +``` + +--- + +## Task 2: MediaMTX downloader + +**Files:** +- Create: `scripts/download_mediamtx.sh` + +This script is a one-time installer. No test — it hits the network. Verified manually. + +- [ ] **Step 1: Create the script** + +Write `scripts/download_mediamtx.sh`: + +```bash +#!/usr/bin/env bash +# Download the MediaMTX static binary used by scripts/sim_cameras.sh. +# One-time setup. Re-run to upgrade. +set -euo pipefail + +VERSION="${MEDIAMTX_VERSION:-1.9.3}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST_DIR="${REPO_ROOT}/.sim" +DEST="${DEST_DIR}/mediamtx" + +case "$(uname -s)_$(uname -m)" in + Linux_x86_64) ASSET="mediamtx_v${VERSION}_linux_amd64.tar.gz" ;; + Linux_aarch64) ASSET="mediamtx_v${VERSION}_linux_arm64v8.tar.gz" ;; + Darwin_x86_64) ASSET="mediamtx_v${VERSION}_darwin_amd64.tar.gz" ;; + Darwin_arm64) ASSET="mediamtx_v${VERSION}_darwin_arm64.tar.gz" ;; + *) + echo "Unsupported platform: $(uname -s) $(uname -m)" >&2 + exit 1 + ;; +esac + +URL="https://github.com/bluenviron/mediamtx/releases/download/v${VERSION}/${ASSET}" + +mkdir -p "${DEST_DIR}" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +echo "Downloading ${ASSET}..." +curl -fsSL "${URL}" -o "${TMP}/mediamtx.tar.gz" + +echo "Extracting..." +tar -xzf "${TMP}/mediamtx.tar.gz" -C "${TMP}" + +mv "${TMP}/mediamtx" "${DEST}" +chmod +x "${DEST}" + +echo "Installed: ${DEST}" +"${DEST}" --version || true +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x scripts/download_mediamtx.sh +``` + +- [ ] **Step 3: Syntax check** + +Run: `bash -n scripts/download_mediamtx.sh` +Expected: no output, exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/download_mediamtx.sh +git commit -m "feat(sim): add MediaMTX binary downloader" +``` + +--- + +## Task 3: Syntax-check test (TDD guard) + +**Files:** +- Create: `tests/unit/test_sim_script.py` + +This test guards both shell scripts against syntax regressions. It runs `bash -n` (always) and `shellcheck` (if installed). Written before `sim_cameras.sh` exists — it must fail initially, then pass once Task 4 creates the file. + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/test_sim_script.py`: + +```python +"""Guard shell scripts in scripts/ against syntax regressions.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = [ + REPO_ROOT / "scripts" / "sim_cameras.sh", + REPO_ROOT / "scripts" / "download_mediamtx.sh", +] + + +@pytest.mark.parametrize("script", SCRIPTS, ids=lambda p: p.name) +def test_script_exists(script: Path) -> None: + assert script.is_file(), f"missing script: {script}" + + +@pytest.mark.parametrize("script", SCRIPTS, ids=lambda p: p.name) +def test_bash_syntax(script: Path) -> None: + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"bash -n failed:\n{result.stderr}" + + +@pytest.mark.parametrize("script", SCRIPTS, ids=lambda p: p.name) +def test_shellcheck(script: Path) -> None: + if shutil.which("shellcheck") is None: + pytest.skip("shellcheck not installed") + result = subprocess.run( + ["shellcheck", "--severity=warning", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"shellcheck failed:\n{result.stdout}\n{result.stderr}" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/unit/test_sim_script.py -v` +Expected: `test_script_exists[sim_cameras.sh]` and `test_bash_syntax[sim_cameras.sh]` FAIL (file does not yet exist). Tests for `download_mediamtx.sh` pass. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add tests/unit/test_sim_script.py +git commit -m "test(sim): guard simulator shell scripts with bash -n + shellcheck" +``` + +Note: committing a red test is intentional here — Task 4 makes it green. + +--- + +## Task 4: sim_cameras.sh — the simulator itself + +**Files:** +- Create: `scripts/sim_cameras.sh` + +This is the main deliverable. Written in one pass because the script is tightly coupled (trap cleanup, PID tracking, prereq checks all reference each other). After writing, the Task 3 tests go green. + +- [ ] **Step 1: Write the script** + +Create `scripts/sim_cameras.sh`: + +```bash +#!/usr/bin/env bash +# Simulate RTSP cameras by looping mp4 files through MediaMTX + ffmpeg. +# +# Usage: ./scripts/sim_cameras.sh +# +# Reads camera ids from config/vigilar.toml, expects videos/.mp4 for each, +# starts MediaMTX on 127.0.0.1:8554, spawns one ffmpeg per camera, and writes +# config/vigilar.sim.toml with rtsp_urls rewritten to the local endpoints. +# Ctrl-C tears everything down. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REAL_CONFIG="${REPO_ROOT}/config/vigilar.toml" +SIM_CONFIG="${REPO_ROOT}/config/vigilar.sim.toml" +VIDEOS_DIR="${REPO_ROOT}/videos" +SIM_DIR="${REPO_ROOT}/.sim" +LOG_DIR="${SIM_DIR}/logs" +MEDIAMTX_BIN="${SIM_DIR}/mediamtx" +RTSP_HOST="127.0.0.1" +RTSP_PORT="8554" + +MEDIAMTX_PID="" +FFMPEG_PIDS=() + +cleanup() { + local pid + for pid in "${FFMPEG_PIDS[@]}"; do + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + kill "${pid}" 2>/dev/null || true + fi + done + if [[ -n "${MEDIAMTX_PID}" ]] && kill -0 "${MEDIAMTX_PID}" 2>/dev/null; then + kill "${MEDIAMTX_PID}" 2>/dev/null || true + fi + # Give children a moment to exit, then force + sleep 0.3 + for pid in "${FFMPEG_PIDS[@]}" "${MEDIAMTX_PID}"; do + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + kill -9 "${pid}" 2>/dev/null || true + fi + done + rm -rf "${SIM_DIR:?}/logs" +} +trap cleanup EXIT INT TERM + +die() { echo "error: $*" >&2; exit 1; } + +# --- prereq checks --------------------------------------------------------- + +command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found on PATH. Install it and retry." +[[ -x "${MEDIAMTX_BIN}" ]] || die "MediaMTX binary not found at ${MEDIAMTX_BIN}. Run: scripts/download_mediamtx.sh" +[[ -f "${REAL_CONFIG}" ]] || die "config file not found: ${REAL_CONFIG}" +[[ -d "${VIDEOS_DIR}" ]] || die "videos directory not found: ${VIDEOS_DIR} (create it and drop .mp4 files inside)" + +# --- parse camera ids from vigilar.toml ------------------------------------ +# Grab every `id = "..."` line that sits inside a [[cameras]] block. +# Simple state machine in awk — no TOML parser needed. + +mapfile -t CAMERA_IDS < <(awk ' + /^\[\[cameras\]\]/ { in_cams = 1; next } + /^\[/ && !/^\[\[cameras\]\]/ { in_cams = 0 } + in_cams && /^[[:space:]]*id[[:space:]]*=/ { + match($0, /"([^"]+)"/, m) + if (m[1] != "") print m[1] + } +' "${REAL_CONFIG}") + +[[ ${#CAMERA_IDS[@]} -gt 0 ]] || die "no [[cameras]] blocks with an id field found in ${REAL_CONFIG}" + +# --- verify every camera has a matching video ----------------------------- + +MISSING=() +for id in "${CAMERA_IDS[@]}"; do + [[ -f "${VIDEOS_DIR}/${id}.mp4" ]] || MISSING+=("${VIDEOS_DIR}/${id}.mp4") +done +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "error: missing video files for cameras:" >&2 + for f in "${MISSING[@]}"; do echo " - ${f}" >&2; done + exit 1 +fi + +# --- start MediaMTX -------------------------------------------------------- + +mkdir -p "${LOG_DIR}" +echo "Starting MediaMTX on ${RTSP_HOST}:${RTSP_PORT}..." +"${MEDIAMTX_BIN}" >"${LOG_DIR}/mediamtx.log" 2>&1 & +MEDIAMTX_PID=$! + +# Wait for the RTSP port to be listening (max ~3s) +for _ in $(seq 1 30); do + if (exec 3<>"/dev/tcp/${RTSP_HOST}/${RTSP_PORT}") 2>/dev/null; then + exec 3<&- 3>&- + break + fi + sleep 0.1 +done +if ! (exec 3<>"/dev/tcp/${RTSP_HOST}/${RTSP_PORT}") 2>/dev/null; then + echo "MediaMTX failed to bind ${RTSP_HOST}:${RTSP_PORT}. Log tail:" >&2 + tail -n 20 "${LOG_DIR}/mediamtx.log" >&2 || true + exit 1 +fi +exec 3<&- 3>&- 2>/dev/null || true + +# --- spawn one ffmpeg per camera ------------------------------------------- + +for id in "${CAMERA_IDS[@]}"; do + video="${VIDEOS_DIR}/${id}.mp4" + url="rtsp://${RTSP_HOST}:${RTSP_PORT}/${id}" + echo "Publishing ${video} -> ${url}" + ffmpeg -nostdin -loglevel warning \ + -stream_loop -1 -re -i "${video}" \ + -c copy -f rtsp "${url}" \ + >"${LOG_DIR}/${id}.log" 2>&1 & + FFMPEG_PIDS+=($!) +done + +# --- write the sim config -------------------------------------------------- +# Copy vigilar.toml, rewrite each `rtsp_url = "..."` line to point at the +# local MediaMTX endpoint for that camera. We rely on `id` preceding +# `rtsp_url` within each [[cameras]] block (the standing convention in +# config/vigilar.toml). + +awk -v host="${RTSP_HOST}" -v port="${RTSP_PORT}" ' + /^\[\[cameras\]\]/ { in_cams = 1; current_id = ""; print; next } + /^\[/ && !/^\[\[cameras\]\]/ { in_cams = 0; print; next } + in_cams && /^[[:space:]]*id[[:space:]]*=/ { + match($0, /"([^"]+)"/, m) + current_id = m[1] + print + next + } + in_cams && /^[[:space:]]*rtsp_url[[:space:]]*=/ && current_id != "" { + printf "rtsp_url = \"rtsp://%s:%s/%s\"\n", host, port, current_id + next + } + { print } +' "${REAL_CONFIG}" > "${SIM_CONFIG}" + +# --- summary --------------------------------------------------------------- + +echo +echo "Simulator running. ${#CAMERA_IDS[@]} stream(s):" +for id in "${CAMERA_IDS[@]}"; do + echo " rtsp://${RTSP_HOST}:${RTSP_PORT}/${id}" +done +echo +echo "Sim config written to: ${SIM_CONFIG}" +echo "Start Vigilar in another terminal:" +echo " vigilar start --config ${SIM_CONFIG#${REPO_ROOT}/}" +echo +echo "Press Ctrl-C to stop." + +wait +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x scripts/sim_cameras.sh +``` + +- [ ] **Step 3: Run the Task 3 tests, expect green** + +Run: `pytest tests/unit/test_sim_script.py -v` +Expected: all non-skipped tests PASS. If `shellcheck` is installed, those tests also pass; if not, they skip. + +If `shellcheck` reports real issues, fix them in the script and re-run. (Common ones: unquoted variables, `[[ ]]` vs `[ ]`, array expansion.) + +- [ ] **Step 4: Manual smoke test — prereq errors** + +Run: `./scripts/sim_cameras.sh` (with no `videos/` directory present) +Expected: exits non-zero with a clear "videos directory not found" message and does not leave any mediamtx or ffmpeg processes running (`pgrep mediamtx; pgrep -f "ffmpeg.*stream_loop"` both empty). + +- [ ] **Step 5: Manual smoke test — happy path** + +Prereqs: run `./scripts/download_mediamtx.sh` once. Then create `videos/` and drop one `.mp4` matching a camera id from `config/vigilar.toml`. (Any short mp4 works — even a few seconds of test footage.) If only one video is ready, temporarily comment out the other `[[cameras]]` blocks in a scratch copy, or add placeholder mp4s for each. + +Run: `./scripts/sim_cameras.sh` +Expected: + - Prints "Publishing ..." for each camera + - Prints "Simulator running. N stream(s):" with URLs + - `ffprobe rtsp://127.0.0.1:8554/` in another terminal returns stream info + - `config/vigilar.sim.toml` exists and every `rtsp_url` in it points at `rtsp://127.0.0.1:8554/...` + +Ctrl-C, then verify cleanup: `pgrep mediamtx` and `pgrep -f "ffmpeg.*stream_loop"` are both empty. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/sim_cameras.sh +git commit -m "feat(sim): add camera stream simulator script" +``` + +--- + +## Task 5: End-to-end verification with Vigilar + +No code changes. This is the acceptance checkpoint from the spec. Skip if you're running the plan in a subagent that can't launch Vigilar; in that case, mark this task complete only after a human runs it. + +- [ ] **Step 1: Start the simulator** + +```bash +./scripts/sim_cameras.sh +``` + +Leave it running in its own terminal. + +- [ ] **Step 2: Start Vigilar against the sim config** + +In a second terminal: + +```bash +vigilar start --config config/vigilar.sim.toml +``` + +- [ ] **Step 3: Verify in the web UI** + +Open the Vigilar web UI. Expected: + - Every simulated camera appears as a tile with live HLS playback. + - Motion in the source footage produces motion events in the event log. + +- [ ] **Step 4: Verify reconnect path** + +Find one ffmpeg PID from the simulator's terminal (or `pgrep -f "ffmpeg.*stream_loop"`) and `kill` it. Expected: + - Vigilar marks that camera disconnected. + - Reconnect loop retries periodically (check logs). + - Re-running `./scripts/sim_cameras.sh` (after Ctrl-C'ing the current one) restores all streams. + +- [ ] **Step 5: Verify clean shutdown** + +Ctrl-C the simulator. Run `pgrep mediamtx` and `pgrep -f "ffmpeg.*stream_loop"` — both empty. `.sim/logs/` is gone (the binary at `.sim/mediamtx` stays — it's the installed artifact). + +- [ ] **Step 6: No commit** + +Task 5 is verification only. If any step fails, return to Task 4 and fix. + +--- + +## Done criteria + +- All 5 tasks checked off. +- `pytest tests/unit/test_sim_script.py -v` is green. +- A human has completed the Task 5 acceptance walk-through at least once. +- `git status` is clean; nothing from `.sim/`, `videos/`, or `config/vigilar.sim.toml` is tracked.