About Multi-camera viewer optimized for RTSP streams
0

Configure Feed

Select the types of activity you want to include in your feed.

at master 14 kB View raw
1import json 2import re 3import socket 4import struct 5import time 6from urllib.parse import quote, unquote, urlparse 7 8 9def _parse_rtsp_url(rtsp_url: str): 10 """Best-effort RTSP URL parsing (host/port/user/pass).""" 11 try: 12 u = urlparse(rtsp_url, allow_fragments=False) 13 if not u.hostname and "#" in rtsp_url: 14 sanitized = rtsp_url.replace("#", "%23") 15 u = urlparse(sanitized, allow_fragments=False) 16 if u.hostname: 17 return u.hostname, u.port or 554, unquote(u.username or ""), unquote(u.password or "") 18 except Exception: 19 pass 20 21 try: 22 pattern = r"^[a-z]+://(?:([^:@/]+)(?::([^@/]*))?@)?([^:/]+)(?::(\d+))?" 23 match = re.match(pattern, rtsp_url or "", re.IGNORECASE) 24 if not match: 25 return None, 554, None, None 26 return ( 27 match.group(3), 28 int(match.group(4) or 554), 29 unquote(match.group(1) or ""), 30 unquote(match.group(2) or ""), 31 ) 32 except Exception: 33 return None, 554, None, None 34 35 36def _normalize_rtsp_url(rtsp_url: str) -> str: 37 """Normalize RTSP URL to always include explicit port to avoid FFmpeg TCP fallback errors.""" 38 try: 39 u = urlparse(rtsp_url) 40 if not u.scheme or u.scheme not in ('rtsp', 'rtsps'): 41 return rtsp_url 42 43 # Ensure port is explicit 44 port = u.port or 554 45 host = u.hostname 46 if not host: 47 return rtsp_url 48 49 # Rebuild URL with explicit port 50 auth = f"{u.username}:{u.password}@" if u.username else "" 51 path = u.path or "/" 52 query = f"?{u.query}" if u.query else "" 53 54 return f"{u.scheme}://{auth}{host}:{port}{path}{query}" 55 except Exception: 56 return rtsp_url 57 58 59def _is_battery_camera(model: str, name: str = "") -> bool: 60 """Check if camera is battery-powered based on model/name.""" 61 if not model and not name: 62 return False 63 64 search_text = f"{model} {name}".lower() 65 66 # Known battery camera series/models 67 battery_keywords = [ 68 "argus", # Argus series (Argus 2, 3, PT, Eco, Ultra) 69 "go", # Go series (Go, Go Plus, Go PT) 70 "altas", # Altas PT Ultra 71 "battery", # Explicit battery mention 72 "solar", # Solar-powered (usually battery) 73 ] 74 75 return any(keyword in search_text for keyword in battery_keywords) 76 77 78def _build_rtsp_url(host: str, port: int = 554, username: str = "", password: str = "", 79 path: str = "h264Preview_01_main", scheme: str = "rtsp") -> str: 80 """Build RTSP URL with proper URL-encoding for credentials containing special characters. 81 82 Args: 83 host: Camera IP or hostname 84 port: RTSP port (default 554) 85 username: Username (will be URL-encoded) 86 password: Password (will be URL-encoded) 87 path: RTSP path (default h264Preview_01_main) 88 scheme: URL scheme (rtsp or rtsps) 89 90 Returns: 91 Properly formatted RTSP URL with encoded credentials 92 """ 93 # URL-encode credentials to handle special characters like #, @, :, etc. 94 # safe='' means encode ALL special characters 95 encoded_user = quote(username, safe='') if username else '' 96 encoded_pass = quote(password, safe='') if password else '' 97 98 # Build auth string 99 if encoded_user and encoded_pass: 100 auth = f"{encoded_user}:{encoded_pass}@" 101 elif encoded_user: 102 auth = f"{encoded_user}@" 103 else: 104 auth = "" 105 106 # Ensure path starts with / 107 if path and not path.startswith('/'): 108 path = f"/{path}" 109 110 return f"{scheme}://{auth}{host}:{port}{path}" 111 112 113def _reolinkproxy_camera_name(name: str) -> str: 114 """Normalize camera name for ReolinkProxy stream path.""" 115 return (name or "Camera").strip().replace(" ", "_") 116 117 118def _reolinkproxy_rtsp_url(name: str, port: int = 8554) -> str: 119 cam_name = _reolinkproxy_camera_name(name) 120 return f"rtsp://localhost:{port}/{cam_name}/mainStream" 121 122 123def _reolinkproxy_proxy_config(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> dict | None: 124 """Build a persistent proxy config for Reolink WLAN/Battery cameras.""" 125 host, port, user, pwd = _parse_rtsp_url(rtsp_url) 126 if host in ("localhost", "127.0.0.1") and port == 8554: 127 return None 128 129 is_reolink = ( 130 (manufacturer or "").lower() == "reolink" 131 or "reolink" in (model or "").lower() 132 or port == 9000 133 ) 134 use_reolinkproxy = port == 9000 or _is_battery_camera(model, name) 135 136 if not (is_reolink and use_reolinkproxy and host): 137 return None 138 139 proxy_port = int(port or 9000) 140 141 return { 142 "type": "reolinkproxy", 143 "host": host, 144 "port": proxy_port, 145 "username": username or user or "", 146 "password": password or pwd or "", 147 "stream": "main", 148 "battery": True, 149 "pause_on_client": True, 150 "idle_disconnect": True, 151 "idle_timeout": "30s", 152 } 153 154 155def normalize_reolinkproxy_camera(camera: dict, username: str = "", password: str = "") -> bool: 156 """Normalize a camera dict to use ReolinkProxy when it represents a Reolink battery/Baichuan camera.""" 157 url = (camera.get("url") or "").strip() 158 if not url: 159 return False 160 161 proxy_config = _reolinkproxy_proxy_config( 162 rtsp_url=url, 163 name=camera.get("name", ""), 164 username=username, 165 password=password, 166 uid=camera.get("uid", ""), 167 model=camera.get("model", ""), 168 manufacturer=camera.get("manufacturer", ""), 169 ) 170 changed = False 171 if proxy_config: 172 new_url = _reolinkproxy_rtsp_url(camera.get("name", "")) 173 if camera.get("url") != new_url: 174 camera["url"] = new_url 175 changed = True 176 if camera.get("proxy") != proxy_config: 177 camera["proxy"] = proxy_config 178 changed = True 179 return changed 180 181 182def _tcp_probe(host: str, port: int, timeout: float = 0.7) -> tuple[bool, str]: 183 """Fast TCP reachability check. 184 185 Returns (ok, reason) where reason is one of: ok, timeout, refused, unreachable, error. 186 """ 187 if not host: 188 return False, "error" 189 try: 190 sock = socket.create_connection((host, int(port)), timeout=timeout) 191 sock.close() 192 return True, "ok" 193 except ConnectionRefusedError: 194 return False, "refused" 195 except TimeoutError: 196 return False, "timeout" 197 except OSError as e: 198 # e.g. No route to host, network unreachable, etc. 199 if getattr(e, "errno", None) in (101, 113): 200 return False, "unreachable" 201 return False, "error" 202 203 204def _ws_discovery(timeout: float = 2.0) -> list[str]: 205 """ONVIF/WS-Discovery (UDP 3702).""" 206 msg = ( 207 '<?xml version="1.0" encoding="utf-8"?>' 208 '<Envelope xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns="http://www.w3.org/2003/05/soap-envelope">' 209 '<Header><MessageID xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">uuid:8253()</MessageID>' 210 '<To xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">urn:schemas-xmlsoap-org:ws:2004:08:discovery</To>' 211 '<Action xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">http://schemas.xmlsoap.org/ws/2004/08/discovery/Probe</Action></Header>' 212 '<Body><Probe xmlns="http://schemas.xmlsoap.org/ws/2004/08/discovery"><Types>tds:Device</Types></Probe></Body></Envelope>' 213 ) 214 ips = set() 215 sock = None 216 try: 217 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 218 sock.settimeout(timeout) 219 sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 220 sock.sendto(msg.encode(), ("239.255.255.250", 3702)) 221 222 while True: 223 try: 224 data, addr = sock.recvfrom(4096) 225 ips.add(addr[0]) 226 except socket.timeout: 227 break 228 except Exception: 229 pass 230 finally: 231 if sock is not None: 232 sock.close() 233 return list(ips) 234 235 236def _ssdp_discovery(timeout: float = 2.0) -> list[str]: 237 """UPnP/SSDP Discovery (UDP 1900).""" 238 msg = ( 239 'M-SEARCH * HTTP/1.1\r\n' 240 'HOST: 239.255.255.250:1900\r\n' 241 'MAN: "ssdp:discover"\r\n' 242 'MX: 2\r\n' 243 'ST: ssdp:all\r\n' 244 '\r\n' 245 ) 246 ips = set() 247 sock = None 248 try: 249 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 250 sock.settimeout(timeout) 251 sock.sendto(msg.encode(), ("239.255.255.250", 1900)) 252 253 while True: 254 try: 255 data, addr = sock.recvfrom(4096) 256 ips.add(addr[0]) 257 except socket.timeout: 258 break 259 except Exception: 260 pass 261 finally: 262 if sock is not None: 263 sock.close() 264 return list(ips) 265 266 267def _udp_reolink_probe(ip: str, timeout: float = 2.0) -> list | dict | None: 268 """Sendet ein Reolink UDP Discovery Paket an eine spezifische oder Broadcast IP.""" 269 ports = [9000, 10000, 2000] 270 271 # Discovery JSON Payloads (GetDevInfo und Search) 272 payloads = [ 273 [{"cmd": "GetDevInfo", "action": 0, "param": {}}], 274 {"cmd": "GetDevInfo", "action": 0, "param": {}}, 275 [{"cmd": "Search", "action": 0, "param": {}}], 276 {"cmd": "Search", "action": 0, "param": {}} 277 ] 278 279 results = [] 280 281 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 282 sock.settimeout(timeout) 283 if ip == "255.255.255.255": 284 sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 285 286 for port in ports: 287 for cmd_data in payloads: 288 data = json.dumps(cmd_data).encode('utf-8') 289 for endian in ['<', '>']: 290 header = struct.pack(endian + "2sHHHII", b"BC", 0, 1, 0, len(data), 0) 291 payload = header + data 292 try: 293 # Weck-Schuss 294 sock.sendto(b"\x00" * 32, (ip, port)) 295 sock.sendto(payload, (ip, port)) 296 297 while True: 298 try: 299 resp_data, addr = sock.recvfrom(4096) 300 except socket.timeout: 301 break 302 303 if len(resp_data) >= 16: 304 idx = -1 305 for i in range(len(resp_data) - 1): 306 if resp_data[i:i+2].lower() == b"bc": 307 for j in range(i+2, min(i+48, len(resp_data))): 308 if resp_data[j] in (ord('['), ord('{')): 309 idx = j 310 break 311 if idx != -1: 312 break 313 314 if idx != -1: 315 content = resp_data[idx:].decode('utf-8', 'ignore') 316 if content.startswith('['): 317 end = content.rfind(']') 318 else: 319 end = content.rfind('}') 320 321 if end != -1: 322 try: 323 res = json.loads(content[:end+1]) 324 info = None 325 # Wir suchen nach DevInfo oder Search-Response 326 val = res[0].get('value', {}) if isinstance(res, list) else res.get('value', {}) 327 info = val.get('DevInfo') or val.get('SearchResult') or val 328 329 if info and (info.get('name') or info.get('serial') or info.get('mac')): 330 info['remote_ip'] = addr[0] 331 if ip != "255.255.255.255": 332 sock.close() 333 return info 334 if info.get('remote_ip') not in [r.get('remote_ip') for r in results]: 335 results.append(info) 336 except Exception: 337 pass 338 if ip != "255.255.255.255": 339 break 340 except Exception: 341 break 342 if ip != "255.255.255.255" and results: 343 break 344 345 sock.close() 346 return results if ip == "255.255.255.255" else (results[0] if results else None) 347 348 349def _udp_reolink_wake(ip: str, uid: str = ""): 350 """Sendet einen intensiven Weck-Burst an eine Reolink Kamera.""" 351 if not ip: 352 return 353 sock = None 354 try: 355 # Falls UID vorhanden, bauen wir ein echtes Abfrage-Paket 356 payload = b"" 357 if uid: 358 msg = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] 359 data = json.dumps(msg).encode('utf-8') 360 header = struct.pack("<2sHHHII", b"BC", 0, 1, 0, len(data), 0) 361 payload = header + data 362 363 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 364 # Wir pingen alle relevanten Ports mehrfach 365 for port in [9000, 10000, 8000]: 366 for _ in range(5): 367 # Null-Bytes zum "Aufwecken" des WLAN/PIR 368 sock.sendto(b"\x00" * 64, (ip, port)) 369 if payload: 370 # Gezielte Abfrage mit UID (Baichuan) 371 sock.sendto(payload, (ip, port)) 372 else: 373 # Generischer Header 374 h = struct.pack("<2sHHHII", b"BC", 0, 1, 0, 0, 0) 375 sock.sendto(h, (ip, port)) 376 time.sleep(0.02) 377 except Exception: 378 pass 379 finally: 380 if sock is not None: 381 sock.close()