Files
NODEDC_MISSION_CORE/apps/node-agent/sensors/media.py
T

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}