"""Private WebRTC preview. No capture ownership, relay, STUN or public candidates.""" import asyncio import ipaddress import json import time import uuid from fractions import Fraction import aioice.ice from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack from av import VideoFrame def private(address): try: value = ipaddress.ip_address(address) return value.version == 4 and ( value.is_loopback or any( value in ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10") ) ) except ValueError: return False host_addresses = aioice.ice.get_host_addresses aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [ v for v in host_addresses(use_ipv4=True, use_ipv6=False) if private(v) ] class CameraTrack(VideoStreamTrack): def __init__(self, device, layer): super().__init__() self.device, self.layer = device, layer self.started = None self.sequence = 0 async def recv(self): # Fixed 15 Hz preview clock; raw hardware timing is independent. if self.started is None: self.started = time.monotonic() await asyncio.sleep(max(0, self.started + self.sequence / 15 - time.monotonic())) pts, base = self.sequence * 6000, Fraction(1, 90000) self.sequence += 1 while self.layer not in self.device.images: await asyncio.sleep(0.1) frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24") frame.pts, frame.time_base = pts, base return frame class Peers: def __init__(self): self.items = {} async def offer(self, device, params): layer = params.get("layer", "color") if layer not in ("color", "depth", "infrared1", "infrared2", "points", "motion"): raise ValueError("Неизвестный слой камеры.") if len(self.items) >= 4: raise ValueError("Закройте лишние окна просмотра камеры.") sdp = params.get("sdp", "") if not isinstance(sdp, str) or len(sdp) > 32768: raise ValueError("Некорректное приглашение просмотра.") for line in sdp.splitlines(): if line.startswith("a=candidate:"): fields = line.split() if len(fields) < 8 or (not private(fields[4]) and not fields[4].endswith(".local")): raise ValueError("Просмотр доступен только в частной сети.") pc = RTCPeerConnection(RTCConfiguration(iceServers=[])) ident = "peer_" + uuid.uuid4().hex self.items[ident] = {"pc": pc, "seen": time.monotonic()} async def telemetry(channel): try: while pc.connectionState not in ("failed", "closed"): if time.monotonic() - self.items.get(ident, {}).get("seen", 0) > 30: break if channel.readyState == "open" and channel.bufferedAmount < 65536: payload = { "layer": layer, "motion": device.motion, "frame": device.last_frame, "acquisition": device.acquisition, } if layer == "points": payload["points"] = await asyncio.to_thread(device.points) channel.send(json.dumps(payload, allow_nan=False)) await asyncio.sleep(0.25) finally: await self.close(ident) @pc.on("datachannel") def datachannel(channel): @channel.on("message") def message(value): if value == "keepalive" and ident in self.items: self.items[ident]["seen"] = time.monotonic() asyncio.create_task(telemetry(channel)) @pc.on("connectionstatechange") async def changed(): if pc.connectionState in ("failed", "closed"): self.items.pop(ident, None) try: await pc.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="offer")) if layer not in ("points", "motion"): pc.addTrack(CameraTrack(device, layer)) await pc.setLocalDescription(await pc.createAnswer()) async def expiry(): await asyncio.sleep(30) if ident in self.items and pc.connectionState != "connected": await self.close(ident) asyncio.create_task(expiry()) return {"peer_id": ident, "sdp": pc.localDescription.sdp, "type": "answer"} except Exception: await self.close(ident) raise async def close(self, ident): entry = self.items.pop(ident, None) if entry: await entry["pc"].close() return {"ok": True}