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.
Files changed (47) hide show
  1. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/PKG-INFO +1 -1
  2. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/_version.py +3 -3
  3. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/client.py +81 -2
  4. pycloudedge-0.1.7/cloudedge/p2p/__init__.py +3 -0
  5. pycloudedge-0.1.7/cloudedge/p2p/kcp_tunnel.py +500 -0
  6. pycloudedge-0.1.7/cloudedge/p2p/meari_signaling.py +406 -0
  7. pycloudedge-0.1.7/cloudedge/p2p/p2p_streamer.py +1640 -0
  8. pycloudedge-0.1.7/cloudedge/p2p/root_discovery.py +230 -0
  9. pycloudedge-0.1.7/cloudedge/p2p/turn_client.py +483 -0
  10. pycloudedge-0.1.7/examples/stream_test.py +263 -0
  11. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/PKG-INFO +1 -1
  12. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/SOURCES.txt +11 -1
  13. pycloudedge-0.1.7/pycloudedge.egg-info/scm_file_list.json +40 -0
  14. pycloudedge-0.1.7/pycloudedge.egg-info/scm_version.json +8 -0
  15. pycloudedge-0.1.7/tests/test_streaming_helpers.py +488 -0
  16. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/.env.example +0 -0
  17. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/.gitignore +0 -0
  18. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/LICENSE +0 -0
  19. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/MANIFEST.in +0 -0
  20. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/README.md +0 -0
  21. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/__init__.py +0 -0
  22. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/cli.py +0 -0
  23. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/constants.py +0 -0
  24. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/exceptions.py +0 -0
  25. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/image_decrypt.py +0 -0
  26. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/iot_parameters.py +0 -0
  27. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/logging_config.py +0 -0
  28. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/mqtt.py +0 -0
  29. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/utils.py +0 -0
  30. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/cloudedge/validators.py +0 -0
  31. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/docs/plans/2026-03-30-streaming-video.md +0 -0
  32. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/README.md +0 -0
  33. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/basic_example.py +0 -0
  34. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/device_control.py +0 -0
  35. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/examples/network_ping_status.py +0 -0
  36. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/dependency_links.txt +0 -0
  37. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/entry_points.txt +0 -0
  38. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/requires.txt +0 -0
  39. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pycloudedge.egg-info/top_level.txt +0 -0
  40. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/pyproject.toml +0 -0
  41. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/requirements-dev.txt +0 -0
  42. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/requirements.txt +0 -0
  43. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/setup.cfg +0 -0
  44. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/setup.py +0 -0
  45. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/test_3region_login.py +0 -0
  46. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/tests/test_basic.py +0 -0
  47. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.7}/tests/test_improvements.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.6.dev4
3
+ Version: 0.1.7
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -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.6.dev4'
22
- __version_tuple__ = version_tuple = (0, 1, 6, 'dev4')
21
+ __version__ = version = '0.1.7'
22
+ __version_tuple__ = version_tuple = (0, 1, 7)
23
23
 
24
- __commit_id__ = commit_id = 'ge76f5b0fa'
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,3 @@
1
+ from .p2p_streamer import P2PStreamer
2
+
3
+ __all__ = ["P2PStreamer"]
@@ -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