About Multi-camera viewer optimized for RTSP streams
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()