Files
vigilar/docs/superpowers/plans/2026-04-05-camera-stream-simulator.md
adlee-was-taken 3a6a79bf68 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) <noreply@anthropic.com>
2026-04-05 13:16:22 -04:00

14 KiB

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/<camera_id>. 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
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:

#!/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
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
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:

"""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
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:

#!/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/<id>.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 <camera_id>.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
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 <camera_id>.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/<id> 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
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
./scripts/sim_cameras.sh

Leave it running in its own terminal.

  • Step 2: Start Vigilar against the sim config

In a second terminal:

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.