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>
459 lines
14 KiB
Markdown
459 lines
14 KiB
Markdown
# 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**
|
|
|
|
```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/<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**
|
|
|
|
```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 `<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**
|
|
|
|
```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.
|