pycloudedge 0.1.6.dev4__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/PKG-INFO +1 -1
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/client.py +81 -2
- pycloudedge-0.1.7/cloudedge/p2p/__init__.py +3 -0
- pycloudedge-0.1.7/cloudedge/p2p/kcp_tunnel.py +500 -0
- pycloudedge-0.1.7/cloudedge/p2p/meari_signaling.py +406 -0
- pycloudedge-0.1.7/cloudedge/p2p/p2p_streamer.py +1640 -0
- pycloudedge-0.1.7/cloudedge/p2p/root_discovery.py +230 -0
- pycloudedge-0.1.7/cloudedge/p2p/turn_client.py +483 -0
- pycloudedge-0.1.7/examples/stream_test.py +263 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/SOURCES.txt +11 -1
- pycloudedge-0.1.7/pycloudedge.egg-info/scm_file_list.json +40 -0
- pycloudedge-0.1.7/pycloudedge.egg-info/scm_version.json +8 -0
- pycloudedge-0.1.7/tests/test_streaming_helpers.py +488 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/.env.example +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/.gitignore +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/LICENSE +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/MANIFEST.in +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/README.md +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/__init__.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/constants.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/image_decrypt.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/mqtt.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/docs/plans/2026-03-30-streaming-video.md +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/README.md +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/device_control.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pyproject.toml +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/requirements-dev.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/requirements.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/setup.cfg +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/setup.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/test_3region_login.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/tests/test_basic.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/tests/test_improvements.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 7)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ga9a42e171'
|
|
@@ -57,6 +57,27 @@ _DEVICE_LIST_CATEGORY_LABELS = {
|
|
|
57
57
|
"chime": "Chime",
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
# Extra app fields needed by the native P2P streaming handshake.
|
|
61
|
+
_APP_STREAMING_METADATA_FIELDS = {
|
|
62
|
+
"device_uuid": "deviceUUID",
|
|
63
|
+
"p2p_init": "p2pInit",
|
|
64
|
+
"device_p2p": "deviceP2P",
|
|
65
|
+
"relay_license_id": "relayLicenseID",
|
|
66
|
+
"host_key1": "hostKey1",
|
|
67
|
+
"share_access_sign": "shareAccessSign",
|
|
68
|
+
"as_friend": "asFriend",
|
|
69
|
+
"device_region": "region",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_app_streaming_metadata(device: Dict[str, Any]) -> Dict[str, Any]:
|
|
74
|
+
metadata: Dict[str, Any] = {}
|
|
75
|
+
for normalized_key, source_key in _APP_STREAMING_METADATA_FIELDS.items():
|
|
76
|
+
value = device.get(source_key)
|
|
77
|
+
if value not in (None, ""):
|
|
78
|
+
metadata[normalized_key] = value
|
|
79
|
+
return metadata
|
|
80
|
+
|
|
60
81
|
|
|
61
82
|
def _device_icon_url_from_type_name(device_type_name: Any) -> Optional[str]:
|
|
62
83
|
"""Return URL if deviceTypeName is an http(s) icon URL (Meari/OSS), else None."""
|
|
@@ -197,6 +218,61 @@ class CloudEdgeClient:
|
|
|
197
218
|
self._local_network = None
|
|
198
219
|
self._network_detected = False
|
|
199
220
|
|
|
221
|
+
def create_streamer(
|
|
222
|
+
self,
|
|
223
|
+
device: Dict[str, Any],
|
|
224
|
+
on_video=None,
|
|
225
|
+
on_audio=None,
|
|
226
|
+
on_login=None,
|
|
227
|
+
on_disconnect=None,
|
|
228
|
+
remote: bool = False,
|
|
229
|
+
video_id: int = 0,
|
|
230
|
+
manage_stream_switch: bool = True,
|
|
231
|
+
):
|
|
232
|
+
"""Create a native P2P streamer for a CloudEdge camera device."""
|
|
233
|
+
from .p2p.p2p_streamer import P2PStreamer
|
|
234
|
+
|
|
235
|
+
return P2PStreamer(
|
|
236
|
+
api=self,
|
|
237
|
+
device=device,
|
|
238
|
+
on_video=on_video,
|
|
239
|
+
on_audio=on_audio,
|
|
240
|
+
on_login=on_login,
|
|
241
|
+
on_disconnect=on_disconnect,
|
|
242
|
+
remote=remote,
|
|
243
|
+
video_id=video_id,
|
|
244
|
+
manage_stream_switch=manage_stream_switch,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def refresh_streaming_metadata(self, device: Dict[str, Any]) -> Dict[str, Any]:
|
|
248
|
+
"""Refresh account-specific identifiers used by the P2P handshake.
|
|
249
|
+
|
|
250
|
+
The same physical camera can have different device IDs and host keys
|
|
251
|
+
when it is accessed through accounts in different regions. Always use
|
|
252
|
+
the values returned by the currently authenticated account.
|
|
253
|
+
"""
|
|
254
|
+
serial_number = device.get("serial_number")
|
|
255
|
+
if not serial_number:
|
|
256
|
+
return device
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
current_devices = self.get_all_devices()
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
self._log(
|
|
262
|
+
f"Could not refresh streaming metadata for {serial_number}: {exc}"
|
|
263
|
+
)
|
|
264
|
+
return device
|
|
265
|
+
|
|
266
|
+
for current_device in current_devices:
|
|
267
|
+
if current_device.get("serial_number") == serial_number:
|
|
268
|
+
device.update(current_device)
|
|
269
|
+
self._log(
|
|
270
|
+
f"Refreshed account-specific streaming metadata for {serial_number}"
|
|
271
|
+
)
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
return device
|
|
275
|
+
|
|
200
276
|
def _detect_local_network(self) -> Optional[str]:
|
|
201
277
|
"""Detect the local network subnet."""
|
|
202
278
|
if self._network_detected:
|
|
@@ -1117,6 +1193,7 @@ class CloudEdgeClient:
|
|
|
1117
1193
|
'online': device.get('devStatus') == 1, # Store original API status
|
|
1118
1194
|
'home_id': home_id,
|
|
1119
1195
|
'device_icon_url': icon_url,
|
|
1196
|
+
**_extract_app_streaming_metadata(device),
|
|
1120
1197
|
}
|
|
1121
1198
|
|
|
1122
1199
|
# Get enhanced online status
|
|
@@ -1238,7 +1315,6 @@ class CloudEdgeClient:
|
|
|
1238
1315
|
|
|
1239
1316
|
# Debug: Log the actual response structure
|
|
1240
1317
|
if self.debug:
|
|
1241
|
-
import json
|
|
1242
1318
|
self._log(f"API Response structure: {json.dumps(response_data, indent=2)}")
|
|
1243
1319
|
|
|
1244
1320
|
device_types = ['nvr', 'ipc', 'chime', 'doorbell', 'snap']
|
|
@@ -1263,6 +1339,7 @@ class CloudEdgeClient:
|
|
|
1263
1339
|
'host_key': device.get('hostKey'),
|
|
1264
1340
|
'online': device.get('onLine') == 1,
|
|
1265
1341
|
'device_icon_url': icon_url,
|
|
1342
|
+
**_extract_app_streaming_metadata(device),
|
|
1266
1343
|
}
|
|
1267
1344
|
device_dict['online'] = self._get_enhanced_device_status(device_dict)
|
|
1268
1345
|
standardized_devices.append(device_dict)
|
|
@@ -1285,6 +1362,7 @@ class CloudEdgeClient:
|
|
|
1285
1362
|
'host_key': device.get('hostKey'),
|
|
1286
1363
|
'online': device.get('onLine') == 1,
|
|
1287
1364
|
'device_icon_url': icon_url,
|
|
1365
|
+
**_extract_app_streaming_metadata(device),
|
|
1288
1366
|
}
|
|
1289
1367
|
device_dict['online'] = self._get_enhanced_device_status(device_dict)
|
|
1290
1368
|
standardized_devices.append(device_dict)
|
|
@@ -1809,6 +1887,7 @@ class CloudEdgeClient:
|
|
|
1809
1887
|
'signature': signature,
|
|
1810
1888
|
'action': 'set',
|
|
1811
1889
|
'deviceid': formatted_sn,
|
|
1890
|
+
'target': 'server',
|
|
1812
1891
|
'params': params_b64
|
|
1813
1892
|
}
|
|
1814
1893
|
|
|
@@ -1996,4 +2075,4 @@ class CloudEdgeClient:
|
|
|
1996
2075
|
'ping_timeout': self.ping_timeout,
|
|
1997
2076
|
'local_network': self._detect_local_network(),
|
|
1998
2077
|
'network_detected': self._network_detected
|
|
1999
|
-
}
|
|
2078
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Minimal KCP (KCP protocol) implementation for Meari P2P tunnels.
|
|
4
|
+
|
|
5
|
+
The Meari P2P SDK (libppsdk.so) uses KCP for reliable data delivery over UDP.
|
|
6
|
+
The KCP conversation ID is hardcoded to 0x0c (12).
|
|
7
|
+
|
|
8
|
+
Protocol layers:
|
|
9
|
+
1. IVA handshake (0xFF 0x01 + session IDs + 0x7012 marker) - sent raw
|
|
10
|
+
2. KCP framing (conv=0x0c) for all subsequent data
|
|
11
|
+
3. IVA data frame (0xFF 0x01 + same IDs + 0x7010 + len + data) as KCP payload
|
|
12
|
+
4. PRTP data (NV_MsgHead + JSON + binary) inside IVA data frame
|
|
13
|
+
|
|
14
|
+
KCP segment wire format (24-byte header):
|
|
15
|
+
[0x00] conv 4B LE conversation ID (always 0x0000000c)
|
|
16
|
+
[0x04] cmd 1B command: PUSH=81, ACK=82, WASK=83, WINS=84
|
|
17
|
+
[0x05] frg 1B fragment count (0 = last/only fragment)
|
|
18
|
+
[0x06] wnd 2B LE receive window size
|
|
19
|
+
[0x08] ts 4B LE timestamp (ms)
|
|
20
|
+
[0x0C] sn 4B LE sequence number
|
|
21
|
+
[0x10] una 4B LE unacknowledged sequence number
|
|
22
|
+
[0x14] len 4B LE payload length
|
|
23
|
+
[0x18] data ... payload
|
|
24
|
+
|
|
25
|
+
Ref: https://github.com/skywind3000/kcp
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import struct
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
# KCP constants
|
|
34
|
+
KCP_CONV = 0x0000000c # Meari hardcoded conversation ID
|
|
35
|
+
KCP_CMD_PUSH = 81 # 0x51 - data
|
|
36
|
+
KCP_CMD_ACK = 82 # 0x52 - acknowledgment
|
|
37
|
+
KCP_CMD_WASK = 83 # 0x53 - window probe request
|
|
38
|
+
KCP_CMD_WINS = 84 # 0x54 - window probe response
|
|
39
|
+
KCP_HEADER_SIZE = 24
|
|
40
|
+
KCP_MSS = 1176 # 0x498 - max segment size
|
|
41
|
+
KCP_WND = 4096 # Advertised receive window (large to avoid flow control throttle)
|
|
42
|
+
|
|
43
|
+
_LOGGER = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# IVA frame constants
|
|
46
|
+
IVA_MAGIC = b'\xFF\x01'
|
|
47
|
+
IVA_FRAME_SIZE = 20
|
|
48
|
+
IVA_TYPE_HANDSHAKE = 0x7012
|
|
49
|
+
IVA_TYPE_DATA = 0x7010
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_iva_frame(type_marker, data, session_id1=None, session_id2=None):
|
|
53
|
+
"""Build an IVA frame with given type and optional data.
|
|
54
|
+
|
|
55
|
+
The session IDs (at offsets 4 and 8) must be consistent within a session.
|
|
56
|
+
"""
|
|
57
|
+
if session_id1 is None:
|
|
58
|
+
session_id1 = int.from_bytes(os.urandom(4), "little") & 0x0FFFFFFF
|
|
59
|
+
if session_id2 is None:
|
|
60
|
+
session_id2 = int.from_bytes(os.urandom(4), "little") & 0x0FFFFFFF
|
|
61
|
+
header = struct.pack("<BBHI I HH I",
|
|
62
|
+
0xFF, 0x01, 0, session_id1,
|
|
63
|
+
session_id2,
|
|
64
|
+
0, type_marker,
|
|
65
|
+
len(data),
|
|
66
|
+
)
|
|
67
|
+
return header + data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_iva_data_frame(data, session_id1=None, session_id2=None):
|
|
71
|
+
"""Build an IVA data frame wrapping application data."""
|
|
72
|
+
return _build_iva_frame(IVA_TYPE_DATA, data, session_id1, session_id2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_iva_handshake(session_id1=None, session_id2=None):
|
|
76
|
+
"""Build a 20-byte IVA handshake frame."""
|
|
77
|
+
return _build_iva_frame(IVA_TYPE_HANDSHAKE, b"", session_id1, session_id2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_iva_frame(data):
|
|
81
|
+
"""Parse an IVA frame. Returns (type_marker, session_id1, session_id2, payload) or None."""
|
|
82
|
+
if len(data) < IVA_FRAME_SIZE:
|
|
83
|
+
return None
|
|
84
|
+
if data[0] != 0xFF or data[1] != 0x01:
|
|
85
|
+
return None
|
|
86
|
+
session_id1 = struct.unpack_from("<I", data, 0x04)[0]
|
|
87
|
+
session_id2 = struct.unpack_from("<I", data, 0x08)[0]
|
|
88
|
+
type_marker = struct.unpack_from("<H", data, 0x0E)[0]
|
|
89
|
+
data_len = struct.unpack_from("<I", data, 0x10)[0]
|
|
90
|
+
payload = data[IVA_FRAME_SIZE:IVA_FRAME_SIZE + data_len] if data_len > 0 else b""
|
|
91
|
+
return type_marker, session_id1, session_id2, payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_kcp_segment(cmd, sn=0, una=0, wnd=KCP_WND, ts=0, frg=0, data=b""):
|
|
95
|
+
"""Build a KCP segment."""
|
|
96
|
+
header = struct.pack("<IBBHIIII",
|
|
97
|
+
KCP_CONV,
|
|
98
|
+
cmd,
|
|
99
|
+
frg,
|
|
100
|
+
wnd,
|
|
101
|
+
ts,
|
|
102
|
+
sn,
|
|
103
|
+
una,
|
|
104
|
+
len(data),
|
|
105
|
+
)
|
|
106
|
+
return header + data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_kcp_segment(raw):
|
|
110
|
+
"""Parse a KCP segment. Returns dict or None."""
|
|
111
|
+
if len(raw) < KCP_HEADER_SIZE:
|
|
112
|
+
return None
|
|
113
|
+
conv, cmd, frg, wnd, ts, sn, una, length = struct.unpack_from(
|
|
114
|
+
"<IBBHIIII", raw, 0)
|
|
115
|
+
if conv != KCP_CONV:
|
|
116
|
+
return None
|
|
117
|
+
payload = raw[KCP_HEADER_SIZE:KCP_HEADER_SIZE + length]
|
|
118
|
+
return {
|
|
119
|
+
"conv": conv,
|
|
120
|
+
"cmd": cmd,
|
|
121
|
+
"frg": frg,
|
|
122
|
+
"wnd": wnd,
|
|
123
|
+
"ts": ts,
|
|
124
|
+
"sn": sn,
|
|
125
|
+
"una": una,
|
|
126
|
+
"len": length,
|
|
127
|
+
"data": payload,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class KcpTunnel:
|
|
132
|
+
"""Minimal KCP tunnel for sending/receiving data over UDP P2P.
|
|
133
|
+
|
|
134
|
+
Handles:
|
|
135
|
+
- IVA handshake with consistent session IDs
|
|
136
|
+
- KCP data segments (PUSH/ACK)
|
|
137
|
+
- IVA data framing around application data
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, send_func):
|
|
141
|
+
"""
|
|
142
|
+
Args:
|
|
143
|
+
send_func: callable(data: bytes) -> None
|
|
144
|
+
Function to send raw UDP data to peer.
|
|
145
|
+
"""
|
|
146
|
+
self.send_func = send_func
|
|
147
|
+
self.sn_send = 0 # Next outgoing sequence number
|
|
148
|
+
self.una_recv = 0 # Highest SN seen + 1 (internal tracking)
|
|
149
|
+
self.ts_base = int(time.time() * 1000) & 0xFFFFFFFF
|
|
150
|
+
self.handshake_done = False
|
|
151
|
+
|
|
152
|
+
# Session IDs - consistent across all frames in this session
|
|
153
|
+
self.session_id1 = int.from_bytes(os.urandom(4), "little") & 0x0FFFFFFF
|
|
154
|
+
self.session_id2 = int.from_bytes(os.urandom(4), "little") & 0x0FFFFFFF
|
|
155
|
+
|
|
156
|
+
# Peer's session IDs (from handshake response)
|
|
157
|
+
self.peer_id1 = None
|
|
158
|
+
self.peer_id2 = None
|
|
159
|
+
|
|
160
|
+
# Sent segment buffer for retransmission (sn -> raw_segment_bytes)
|
|
161
|
+
self.sent_segments = {}
|
|
162
|
+
# Track which sn have been ACK'd
|
|
163
|
+
self.acked_sns = set()
|
|
164
|
+
|
|
165
|
+
# Receive-side fragment reassembly buffer
|
|
166
|
+
# Accumulates KCP fragment data for multi-fragment messages.
|
|
167
|
+
# frg counts DOWN: N-1, N-2, ..., 0. frg==0 means last fragment.
|
|
168
|
+
self.recv_frag_buf = []
|
|
169
|
+
|
|
170
|
+
# Receive buffer for out-of-order segments: sn -> (frg, data)
|
|
171
|
+
self.recv_buf = {}
|
|
172
|
+
# Next expected receive sequence number for ordered delivery
|
|
173
|
+
# Initialized to -1 (sentinel); set to peer's first sn on first PUSH
|
|
174
|
+
self.next_recv_sn = -1
|
|
175
|
+
|
|
176
|
+
# Queue of fully reassembled messages ready for delivery
|
|
177
|
+
self.recv_queue = []
|
|
178
|
+
|
|
179
|
+
# Deferred ACK queue: list of (sn, ts) to send in batch
|
|
180
|
+
self.pending_acks = []
|
|
181
|
+
|
|
182
|
+
def _ts(self):
|
|
183
|
+
"""Current KCP timestamp."""
|
|
184
|
+
return (int(time.time() * 1000) - self.ts_base) & 0xFFFFFFFF
|
|
185
|
+
|
|
186
|
+
def send_handshake(self):
|
|
187
|
+
"""Send IVA handshake frame wrapped in KCP PUSH segment."""
|
|
188
|
+
frame = build_iva_handshake(self.session_id1, self.session_id2)
|
|
189
|
+
self.send_data(frame)
|
|
190
|
+
|
|
191
|
+
def wrap_iva(self, data):
|
|
192
|
+
"""Wrap data in IVA data frame with session IDs."""
|
|
193
|
+
return build_iva_data_frame(data, self.session_id1, self.session_id2)
|
|
194
|
+
|
|
195
|
+
def send_data(self, data):
|
|
196
|
+
"""Send data through KCP. Handles fragmentation if needed."""
|
|
197
|
+
offset = 0
|
|
198
|
+
fragments = []
|
|
199
|
+
while offset < len(data):
|
|
200
|
+
chunk = data[offset:offset + KCP_MSS]
|
|
201
|
+
fragments.append(chunk)
|
|
202
|
+
offset += KCP_MSS
|
|
203
|
+
|
|
204
|
+
if not fragments:
|
|
205
|
+
fragments = [b""]
|
|
206
|
+
|
|
207
|
+
# Send fragments (frg counts down: n-1, n-2, ..., 0)
|
|
208
|
+
n = len(fragments)
|
|
209
|
+
for i, chunk in enumerate(fragments):
|
|
210
|
+
frg = n - 1 - i
|
|
211
|
+
seg = build_kcp_segment(
|
|
212
|
+
cmd=KCP_CMD_PUSH,
|
|
213
|
+
sn=self.sn_send,
|
|
214
|
+
una=max(self.next_recv_sn, 0),
|
|
215
|
+
wnd=KCP_WND,
|
|
216
|
+
ts=self._ts(),
|
|
217
|
+
frg=frg,
|
|
218
|
+
data=chunk,
|
|
219
|
+
)
|
|
220
|
+
self.sent_segments[self.sn_send] = (chunk, frg)
|
|
221
|
+
self.send_func(seg)
|
|
222
|
+
self.sn_send += 1
|
|
223
|
+
|
|
224
|
+
def retransmit_unacked(self):
|
|
225
|
+
"""Retransmit all sent segments that haven't been ACK'd yet."""
|
|
226
|
+
for sn in sorted(self.sent_segments.keys()):
|
|
227
|
+
if sn not in self.acked_sns:
|
|
228
|
+
chunk, frg = self.sent_segments[sn]
|
|
229
|
+
seg = build_kcp_segment(
|
|
230
|
+
cmd=KCP_CMD_PUSH,
|
|
231
|
+
sn=sn,
|
|
232
|
+
una=max(self.next_recv_sn, 0),
|
|
233
|
+
wnd=KCP_WND,
|
|
234
|
+
ts=self._ts(),
|
|
235
|
+
frg=frg,
|
|
236
|
+
data=chunk,
|
|
237
|
+
)
|
|
238
|
+
self.send_func(seg)
|
|
239
|
+
|
|
240
|
+
def send_iva_data(self, data):
|
|
241
|
+
"""Wrap data in IVA frame, then send through KCP."""
|
|
242
|
+
iva_frame = self.wrap_iva(data)
|
|
243
|
+
self.send_data(iva_frame)
|
|
244
|
+
|
|
245
|
+
def poll_data(self):
|
|
246
|
+
"""Return the next queued reassembled message, or None."""
|
|
247
|
+
if self.recv_queue:
|
|
248
|
+
return self.recv_queue.pop(0)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def flush_acks(self):
|
|
252
|
+
"""Send all pending ACKs as compound KCP packet(s).
|
|
253
|
+
|
|
254
|
+
Called after processing a batch of incoming packets to reduce
|
|
255
|
+
sendto() syscall overhead. Each compound packet contains multiple
|
|
256
|
+
concatenated 24-byte KCP ACK segments with the latest cumulative UNA.
|
|
257
|
+
"""
|
|
258
|
+
if not self.pending_acks:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
# Deduplicate: keep last ts per sn
|
|
262
|
+
by_sn = {}
|
|
263
|
+
for sn, ts in self.pending_acks:
|
|
264
|
+
by_sn[sn] = ts
|
|
265
|
+
self.pending_acks.clear()
|
|
266
|
+
|
|
267
|
+
# Use cumulative UNA (next_recv_sn) so the camera knows which
|
|
268
|
+
# segments we're still missing and retransmits them (fast retransmit
|
|
269
|
+
# triggers after fastack >= 2). The camera's 128-segment send window
|
|
270
|
+
# fills in ~1.2s; fast retransmit recovers gaps in ~200ms.
|
|
271
|
+
# Gap skip at 2s is the safety net for persistent losses.
|
|
272
|
+
una = max(self.next_recv_sn, 0)
|
|
273
|
+
|
|
274
|
+
# Build compound ACK packet(s), splitting at ~1200 bytes to stay
|
|
275
|
+
# under typical MTU (each ACK segment is 24 bytes → ~50 per packet)
|
|
276
|
+
buf = bytearray()
|
|
277
|
+
for sn in sorted(by_sn):
|
|
278
|
+
buf += build_kcp_segment(
|
|
279
|
+
cmd=KCP_CMD_ACK,
|
|
280
|
+
sn=sn,
|
|
281
|
+
una=una,
|
|
282
|
+
wnd=KCP_WND,
|
|
283
|
+
ts=by_sn[sn],
|
|
284
|
+
)
|
|
285
|
+
if len(buf) >= 1200:
|
|
286
|
+
self.send_func(bytes(buf))
|
|
287
|
+
buf.clear()
|
|
288
|
+
if buf:
|
|
289
|
+
self.send_func(bytes(buf))
|
|
290
|
+
|
|
291
|
+
def send_gap_nudge(self):
|
|
292
|
+
"""Send ACKs for segments above current gap to trigger fast retransmit.
|
|
293
|
+
|
|
294
|
+
When next_recv_sn is missing from recv_buf but higher segments exist,
|
|
295
|
+
the camera's KCP should retransmit the missing segment upon receiving
|
|
296
|
+
these "skip" ACKs. Call periodically when video stalls.
|
|
297
|
+
"""
|
|
298
|
+
if self.next_recv_sn < 0 or not self.recv_buf:
|
|
299
|
+
return False
|
|
300
|
+
# Check if there's actually a gap at next_recv_sn
|
|
301
|
+
if self.next_recv_sn in self.recv_buf:
|
|
302
|
+
return False # No gap — will be assembled on next process_input
|
|
303
|
+
above_gap = sorted(sn for sn in self.recv_buf if sn > self.next_recv_sn)[:20]
|
|
304
|
+
if not above_gap:
|
|
305
|
+
return False
|
|
306
|
+
una = max(self.next_recv_sn, 0)
|
|
307
|
+
buf = bytearray()
|
|
308
|
+
for sn in above_gap:
|
|
309
|
+
buf += build_kcp_segment(
|
|
310
|
+
cmd=KCP_CMD_ACK,
|
|
311
|
+
sn=sn,
|
|
312
|
+
una=una,
|
|
313
|
+
wnd=KCP_WND,
|
|
314
|
+
ts=self._ts(),
|
|
315
|
+
)
|
|
316
|
+
self.send_func(bytes(buf))
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def skip_gap(self):
|
|
320
|
+
"""Skip past ALL persistent gaps to unblock delivery.
|
|
321
|
+
|
|
322
|
+
When next_recv_sn is missing but higher segments are buffered,
|
|
323
|
+
advance next_recv_sn past EVERY gap (not just the first one).
|
|
324
|
+
This loses the missing segment(s) but unblocks delivery of all
|
|
325
|
+
buffered data immediately.
|
|
326
|
+
Returns True if any gap was skipped and messages may be available.
|
|
327
|
+
"""
|
|
328
|
+
if self.next_recv_sn < 0 or not self.recv_buf:
|
|
329
|
+
return False
|
|
330
|
+
if self.next_recv_sn in self.recv_buf:
|
|
331
|
+
return False # No gap
|
|
332
|
+
|
|
333
|
+
any_skipped = False
|
|
334
|
+
total_gaps = 0
|
|
335
|
+
first_skip_sn = self.next_recv_sn
|
|
336
|
+
|
|
337
|
+
# Loop: skip gap, assemble contiguous run, check for next gap, repeat
|
|
338
|
+
while True:
|
|
339
|
+
# Find the lowest buffered SN above the current gap
|
|
340
|
+
above = [sn for sn in self.recv_buf if sn > self.next_recv_sn]
|
|
341
|
+
if not above:
|
|
342
|
+
break
|
|
343
|
+
new_sn = min(above)
|
|
344
|
+
gap_size = new_sn - self.next_recv_sn
|
|
345
|
+
total_gaps += gap_size
|
|
346
|
+
# Discard any partial fragment state (the missing segment
|
|
347
|
+
# was likely a fragment boundary, so existing fragments are stale)
|
|
348
|
+
self.recv_frag_buf = []
|
|
349
|
+
self.next_recv_sn = new_sn
|
|
350
|
+
any_skipped = True
|
|
351
|
+
|
|
352
|
+
# Assemble contiguous segments after the gap
|
|
353
|
+
while self.next_recv_sn in self.recv_buf:
|
|
354
|
+
frg, data = self.recv_buf[self.next_recv_sn]
|
|
355
|
+
self.recv_frag_buf.append(data)
|
|
356
|
+
del self.recv_buf[self.next_recv_sn]
|
|
357
|
+
self.next_recv_sn += 1
|
|
358
|
+
if frg == 0:
|
|
359
|
+
complete = b"".join(self.recv_frag_buf)
|
|
360
|
+
self.recv_frag_buf = []
|
|
361
|
+
self.recv_queue.append(complete)
|
|
362
|
+
|
|
363
|
+
# If next_recv_sn is now in recv_buf (no gap), we're done
|
|
364
|
+
if self.next_recv_sn in self.recv_buf or not self.recv_buf:
|
|
365
|
+
break
|
|
366
|
+
# Otherwise there's another gap — loop and skip it too
|
|
367
|
+
|
|
368
|
+
if any_skipped:
|
|
369
|
+
_LOGGER.debug(
|
|
370
|
+
"KCP skipped gaps: sn %s→%s (%s missing), buf=%s, queued=%s",
|
|
371
|
+
first_skip_sn,
|
|
372
|
+
self.next_recv_sn,
|
|
373
|
+
total_gaps,
|
|
374
|
+
len(self.recv_buf),
|
|
375
|
+
len(self.recv_queue),
|
|
376
|
+
)
|
|
377
|
+
return any_skipped
|
|
378
|
+
|
|
379
|
+
def process_input(self, raw):
|
|
380
|
+
"""Process incoming UDP data.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
- ("handshake", (id1, id2)) for IVA handshake
|
|
384
|
+
- ("data", bytes) for complete KCP data
|
|
385
|
+
- ("ack", sn) for ACK
|
|
386
|
+
- None for unrecognized data
|
|
387
|
+
"""
|
|
388
|
+
if len(raw) < 4:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
# Check for IVA frame (handshake/heartbeat)
|
|
392
|
+
if raw[0] == 0xFF and raw[1] == 0x01:
|
|
393
|
+
iva = parse_iva_frame(raw)
|
|
394
|
+
if iva:
|
|
395
|
+
type_marker, id1, id2, payload = iva
|
|
396
|
+
if type_marker == IVA_TYPE_HANDSHAKE:
|
|
397
|
+
self.handshake_done = True
|
|
398
|
+
self.peer_id1 = id1
|
|
399
|
+
self.peer_id2 = id2
|
|
400
|
+
return ("handshake", (id1, id2))
|
|
401
|
+
if type_marker == IVA_TYPE_DATA:
|
|
402
|
+
return ("iva_data", payload)
|
|
403
|
+
return ("iva", payload)
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
# Check for KCP segment
|
|
407
|
+
seg = parse_kcp_segment(raw)
|
|
408
|
+
if not seg:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
cmd = seg["cmd"]
|
|
412
|
+
|
|
413
|
+
if cmd == KCP_CMD_PUSH:
|
|
414
|
+
# Update una_recv
|
|
415
|
+
if seg["sn"] >= self.una_recv:
|
|
416
|
+
self.una_recv = seg["sn"] + 1
|
|
417
|
+
|
|
418
|
+
# Store in receive buffer for ordered reassembly
|
|
419
|
+
sn = seg["sn"]
|
|
420
|
+
|
|
421
|
+
# Skip duplicate/retransmitted segments we've already processed
|
|
422
|
+
if self.next_recv_sn >= 0 and sn < self.next_recv_sn:
|
|
423
|
+
# Queue ACK with current cumulative una to help sender advance
|
|
424
|
+
self.pending_acks.append((seg["sn"], seg["ts"]))
|
|
425
|
+
return ("dup", sn)
|
|
426
|
+
|
|
427
|
+
self.recv_buf[sn] = (seg["frg"], seg["data"])
|
|
428
|
+
|
|
429
|
+
# Initialize next_recv_sn on first PUSH segment received
|
|
430
|
+
# (camera may start at sn=1 if sn=0 was used for IVA handshake)
|
|
431
|
+
if self.next_recv_sn < 0:
|
|
432
|
+
self.next_recv_sn = sn
|
|
433
|
+
|
|
434
|
+
# Try to assemble complete messages from recv_buf
|
|
435
|
+
# Process segments in order starting from next_recv_sn
|
|
436
|
+
messages = []
|
|
437
|
+
while self.next_recv_sn in self.recv_buf:
|
|
438
|
+
frg, data = self.recv_buf[self.next_recv_sn]
|
|
439
|
+
self.recv_frag_buf.append(data)
|
|
440
|
+
del self.recv_buf[self.next_recv_sn]
|
|
441
|
+
self.next_recv_sn += 1
|
|
442
|
+
|
|
443
|
+
if frg == 0:
|
|
444
|
+
# Last fragment - assemble complete message
|
|
445
|
+
complete = b"".join(self.recv_frag_buf)
|
|
446
|
+
self.recv_frag_buf = []
|
|
447
|
+
messages.append(complete)
|
|
448
|
+
|
|
449
|
+
# Queue ACK for batched sending (flush_acks() sends all at once)
|
|
450
|
+
self.pending_acks.append((seg["sn"], seg["ts"]))
|
|
451
|
+
|
|
452
|
+
if not messages:
|
|
453
|
+
# No complete message yet - still accumulating fragments
|
|
454
|
+
return ("fragment", seg["data"])
|
|
455
|
+
|
|
456
|
+
# Process the first complete message
|
|
457
|
+
# (additional messages queued for later retrieval)
|
|
458
|
+
for msg in messages[1:]:
|
|
459
|
+
self.recv_queue.append(msg)
|
|
460
|
+
|
|
461
|
+
data = messages[0]
|
|
462
|
+
|
|
463
|
+
# Auto-detect IVA frame inside reassembled KCP payload
|
|
464
|
+
if len(data) >= 20 and data[0] == 0xFF and data[1] == 0x01:
|
|
465
|
+
iva = parse_iva_frame(data)
|
|
466
|
+
if iva:
|
|
467
|
+
type_marker, id1, id2, payload = iva
|
|
468
|
+
if type_marker == IVA_TYPE_HANDSHAKE:
|
|
469
|
+
self.handshake_done = True
|
|
470
|
+
self.peer_id1 = id1
|
|
471
|
+
self.peer_id2 = id2
|
|
472
|
+
return ("handshake", (id1, id2))
|
|
473
|
+
if type_marker == IVA_TYPE_DATA:
|
|
474
|
+
return ("data", payload)
|
|
475
|
+
|
|
476
|
+
return ("data", data)
|
|
477
|
+
|
|
478
|
+
elif cmd == KCP_CMD_ACK:
|
|
479
|
+
self.acked_sns.add(seg["sn"])
|
|
480
|
+
# Clean up sent_segments for ACK'd data
|
|
481
|
+
if seg["sn"] in self.sent_segments:
|
|
482
|
+
del self.sent_segments[seg["sn"]]
|
|
483
|
+
return ("ack", seg["sn"])
|
|
484
|
+
|
|
485
|
+
elif cmd == KCP_CMD_WASK:
|
|
486
|
+
# Window probe request - respond with WINS
|
|
487
|
+
wins = build_kcp_segment(
|
|
488
|
+
cmd=KCP_CMD_WINS,
|
|
489
|
+
sn=self.sn_send,
|
|
490
|
+
una=max(self.next_recv_sn, 0),
|
|
491
|
+
wnd=KCP_WND,
|
|
492
|
+
ts=self._ts(),
|
|
493
|
+
)
|
|
494
|
+
self.send_func(wins)
|
|
495
|
+
return ("wask", None)
|
|
496
|
+
|
|
497
|
+
elif cmd == KCP_CMD_WINS:
|
|
498
|
+
return ("wins", seg["wnd"])
|
|
499
|
+
|
|
500
|
+
return None
|