127 lines
4.8 KiB
Python
127 lines
4.8 KiB
Python
"""Private WebRTC preview. No capture ownership, relay, STUN or public candidates."""
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import json
|
|
import time
|
|
import uuid
|
|
|
|
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
|
|
|
|
async def recv(self):
|
|
pts, base = await self.next_timestamp()
|
|
# Bound preview to 15 Hz; hardware profiles and raw recording are independent.
|
|
await asyncio.sleep(1 / 30)
|
|
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}
|