diff --git a/Makefile b/Makefile
index b8e80ec..5410c3b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
-.PHONY: help up down docker-build docker-push run db test coverage typecheck lint check db-migrate db-migration publish ui-%
+.PHONY: help up down docker-build docker-push run db test coverage typecheck lint check db-migrate db-migration publish ui-% fake-camera
help:
@echo "Targets:"
@@ -20,6 +20,7 @@ help:
@echo " make typecheck Run mypy"
@echo " make lint Run ruff linter"
@echo " make check Run lint + typecheck + test + ui-check"
+ @echo " make fake-camera Start a mock ONVIF + RTSP camera (requires ffmpeg, mediamtx)"
@echo ""
@echo " Database:"
@echo " make db-migrate Run migrations"
@@ -87,6 +88,15 @@ lint-fix:
check: lint typecheck test ui-check
+fake-camera:
+ @echo "Starting mock ONVIF server on port 8000..."
+ @python3 dev/fake-camera/mock_onvif.py &
+ @echo "Starting RTSP server on port 8099..."
+ @./mediamtx dev/fake-camera/mediamtx.yml &
+ @sleep 2
+ @echo "Streaming media/sample.mp4 to rtsp://localhost:8099/live..."
+ @ffmpeg -re -stream_loop -1 -i media/sample.mp4 -c copy -f rtsp rtsp://admin:admin123@localhost:8099/live
+
# Database
db-migrate:
uv run --with alembic --with sqlalchemy --with asyncpg --with python-dotenv alembic -c alembic.ini upgrade head
diff --git a/dev/fake-camera/mediamtx.yml b/dev/fake-camera/mediamtx.yml
new file mode 100644
index 0000000..767cb70
--- /dev/null
+++ b/dev/fake-camera/mediamtx.yml
@@ -0,0 +1,17 @@
+rtspAddress: :8099
+rtpAddress: :8100
+rtcpAddress: :8101
+authMethod: internal
+authInternalUsers:
+ - user: admin
+ pass: admin123
+ permissions:
+ - action: publish
+ path: live
+ - action: read
+ path: live
+ - action: playback
+ path: live
+
+paths:
+ live:
diff --git a/dev/fake-camera/mock_onvif.py b/dev/fake-camera/mock_onvif.py
new file mode 100644
index 0000000..fb982ef
--- /dev/null
+++ b/dev/fake-camera/mock_onvif.py
@@ -0,0 +1,230 @@
+import asyncio
+import socket
+import struct
+import uuid
+import re
+from aiohttp import web
+import logging
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+ONVIF_HOST = "localhost"
+ONVIF_PORT = 8000
+RTSP_URL = "rtsp://localhost:8099/live"
+
+WS_DISCOVERY_MULTICAST = "239.255.255.250"
+WS_DISCOVERY_PORT = 3702
+
+DEVICE_XADDR = f"http://{ONVIF_HOST}:{ONVIF_PORT}/onvif/device_service"
+DEVICE_UUID = f"uuid:{uuid.uuid4()}"
+
+DEVICE_SERVICE_XML = f"""
+
+
+
+
+ http://www.onvif.org/ver10/media/wsdl
+ http://{ONVIF_HOST}:{ONVIF_PORT}/onvif/media_service
+
+
+
+"""
+
+DEVICE_INFO_XML = """
+
+
+
+ FakeCam
+ MockCam-1000
+ 1.0.0
+ FAKE-001
+ mock-hw-001
+
+
+"""
+
+CAPABILITIES_XML = f"""
+
+
+
+
+
+ http://{ONVIF_HOST}:{ONVIF_PORT}/onvif/media_service
+
+
+
+
+"""
+
+PROFILES_XML = f"""
+
+
+
+
+ MainStream
+
+ VideoSource
+ 1
+ VS_1
+
+
+
+ H264
+ 1
+ H264
+
+ 768
+ 432
+
+
+ 12
+ 765
+
+
+
+
+
+"""
+
+STREAM_URI_XML = f"""
+
+
+
+
+ {RTSP_URL}
+ false
+ false
+ PT60S
+
+
+
+"""
+
+
+async def handle_device_service(request):
+ body = await request.text()
+ logger.info("Device service request: %s", body[:200])
+
+ if "GetDeviceInformation" in body:
+ return web.Response(text=DEVICE_INFO_XML, content_type="application/soap+xml")
+ if "GetCapabilities" in body:
+ return web.Response(text=CAPABILITIES_XML, content_type="application/soap+xml")
+ if "GetServices" in body:
+ return web.Response(text=DEVICE_SERVICE_XML, content_type="application/soap+xml")
+
+ return web.Response(
+ text='',
+ content_type="application/soap+xml",
+ )
+
+
+async def handle_media_service(request):
+ body = await request.text()
+ logger.info("Media service request: %s", body[:200])
+
+ if "GetProfiles" in body:
+ return web.Response(text=PROFILES_XML, content_type="application/soap+xml")
+ if "GetStreamUri" in body:
+ return web.Response(text=STREAM_URI_XML, content_type="application/soap+xml")
+
+ return web.Response(
+ text='',
+ content_type="application/soap+xml",
+ )
+
+
+def build_probe_match(message_id: str) -> str:
+ return f"""
+
+
+ urn:uuid:{uuid.uuid4()}
+ {message_id}
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous
+ http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatches
+
+
+
+
+
+ {DEVICE_UUID}
+
+ dn:NetworkVideoTransmitter tds:Device
+ onvif://www.onvif.org/type/video_encoder onvif://www.onvif.org/name/FakeCam onvif://www.onvif.org/location/Replit
+ {DEVICE_XADDR}
+ 1
+
+
+
+"""
+
+
+class WSDiscoveryProtocol(asyncio.DatagramProtocol):
+ def __init__(self):
+ self.transport = None
+
+ def connection_made(self, transport):
+ self.transport = transport
+
+ def datagram_received(self, data, addr):
+ try:
+ message = data.decode("utf-8", errors="replace")
+ if "Probe" not in message:
+ return
+
+ msg_id_match = re.search(r"<\w*:?MessageID[^>]*>(.*?)\w*:?MessageID>", message)
+ message_id = msg_id_match.group(1) if msg_id_match else f"urn:uuid:{uuid.uuid4()}"
+
+ logger.info("WS-Discovery Probe from %s (MessageID: %s)", addr, message_id)
+
+ response = build_probe_match(message_id)
+ self.transport.sendto(response.encode("utf-8"), addr)
+ logger.info("Sent ProbeMatch to %s", addr)
+ except Exception:
+ logger.exception("Error handling WS-Discovery probe")
+
+
+async def start_ws_discovery():
+ loop = asyncio.get_event_loop()
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+ except AttributeError:
+ pass
+ sock.bind(("", WS_DISCOVERY_PORT))
+
+ group = socket.inet_aton(WS_DISCOVERY_MULTICAST)
+ mreq = struct.pack("4sL", group, socket.INADDR_ANY)
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+ sock.setblocking(False)
+
+ transport, protocol = await loop.create_datagram_endpoint(
+ WSDiscoveryProtocol, sock=sock
+ )
+ logger.info("WS-Discovery listener started on %s:%d", WS_DISCOVERY_MULTICAST, WS_DISCOVERY_PORT)
+ return transport
+
+
+async def main():
+ await start_ws_discovery()
+
+ app = web.Application()
+ app.router.add_post("/onvif/device_service", handle_device_service)
+ app.router.add_post("/onvif/media_service", handle_media_service)
+
+ runner = web.AppRunner(app)
+ await runner.setup()
+ site = web.TCPSite(runner, "0.0.0.0", ONVIF_PORT)
+ await site.start()
+ logger.info("ONVIF HTTP server started on http://0.0.0.0:%d", ONVIF_PORT)
+
+ await asyncio.Event().wait()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())