From 10674a1abe0d5cfe61691610eb7f4296b82eadd3 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 28 Feb 2026 15:33:04 -0800 Subject: [PATCH] feat: add fake ONVIF + RTSP camera for local development - mock_onvif.py: Mock ONVIF server with WS-Discovery (UDP multicast) and full SOAP endpoints (GetDeviceInformation, GetCapabilities, GetServices, GetProfiles, GetStreamUri) - mediamtx.yml: MediaMTX RTSP server config with auth (admin/admin123) - Makefile: New `make fake-camera` target Prerequisites: ffmpeg, mediamtx binary, and a sample .mp4 at media/sample.mp4. --- Makefile | 12 +- dev/fake-camera/mediamtx.yml | 17 +++ dev/fake-camera/mock_onvif.py | 230 ++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 dev/fake-camera/mediamtx.yml create mode 100644 dev/fake-camera/mock_onvif.py diff --git a/Makefile b/Makefile index b8e80ec2..5410c3b2 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 00000000..767cb709 --- /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 00000000..fb982efa --- /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[^>]*>(.*?)", 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())