@apocaliss92/scrypted-reolink-native 0.1.0 → 0.1.2

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.
@@ -20,6 +20,16 @@ export interface StreamManagerOptions {
20
20
  */
21
21
  createStreamClient: () => Promise<ReolinkBaichuanApi>;
22
22
  getLogger: () => Console;
23
+ /**
24
+ * Credentials to include in the TCP stream (username, password).
25
+ * Uses the same credentials as the main connection.
26
+ */
27
+ credentials: {
28
+ username: string;
29
+ password: string;
30
+ };
31
+ /** If true, the stream client is shared with the main connection. Default: false. */
32
+ sharedConnection?: boolean;
23
33
  }
24
34
 
25
35
  export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
@@ -207,7 +217,7 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
207
217
  }): Promise<MediaObject> {
208
218
  const { streamManager, channel, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
209
219
 
210
- const { host, port, sdp, audio } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
220
+ const { host, port, sdp, audio, username, password } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
211
221
 
212
222
  // Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
213
223
  try {
@@ -230,8 +240,13 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
230
240
  mso.audio.channels = audio.channels;
231
241
  }
232
242
 
243
+ // Build URL with credentials: tcp://username:password@host:port
244
+ const encodedUsername = encodeURIComponent(username || '');
245
+ const encodedPassword = encodeURIComponent(password || '');
246
+ const url = `tcp://${encodedUsername}:${encodedPassword}@${host}:${port}`;
247
+
233
248
  const rfc = {
234
- url: `tcp://${host}:${port}`,
249
+ url,
235
250
  sdp,
236
251
  mediaStreamOptions: mso as ResponseMediaStreamOptions,
237
252
  };
@@ -241,9 +256,18 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
241
256
  });
242
257
  }
243
258
 
259
+ type RfcServerInfo = {
260
+ host: string;
261
+ port: number;
262
+ sdp: string;
263
+ audio?: { codec: string; sampleRate: number; channels: number };
264
+ username: string;
265
+ password: string;
266
+ };
267
+
244
268
  export class StreamManager {
245
269
  private nativeRfcServers = new Map<string, ScryptedRfc4571TcpServer>();
246
- private nativeRfcServerCreatePromises = new Map<string, Promise<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }>>();
270
+ private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
247
271
 
248
272
  constructor(private opts: StreamManagerOptions) {
249
273
  }
@@ -257,7 +281,7 @@ export class StreamManager {
257
281
  channel: number,
258
282
  profile: StreamProfile,
259
283
  expectedVideoType?: 'H264' | 'H265',
260
- ): Promise<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }> {
284
+ ): Promise<RfcServerInfo> {
261
285
  const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
262
286
  if (existingCreate) {
263
287
  return await existingCreate;
@@ -272,7 +296,14 @@ export class StreamManager {
272
296
  );
273
297
  }
274
298
  else {
275
- return { host: cached.host, port: cached.port, sdp: cached.sdp, audio: cached.audio };
299
+ return {
300
+ host: cached.host,
301
+ port: cached.port,
302
+ sdp: cached.sdp,
303
+ audio: cached.audio,
304
+ username: (cached as any).username || this.opts.credentials.username,
305
+ password: (cached as any).password || this.opts.credentials.password,
306
+ };
276
307
  }
277
308
  }
278
309
 
@@ -288,13 +319,22 @@ export class StreamManager {
288
319
 
289
320
  const api = await this.opts.createStreamClient();
290
321
  const { createScryptedRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
322
+
323
+ // Use the same credentials as the main connection
324
+ const { username, password } = this.opts.credentials;
325
+
326
+ // If connection is shared, don't close it when stream teardown happens
327
+ const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
328
+
291
329
  const created = await createScryptedRfc4571TcpServer({
292
330
  api,
293
331
  channel,
294
332
  profile,
295
333
  logger: this.getLogger(),
296
334
  expectedVideoType: expectedVideoType as VideoType | undefined,
297
- closeApiOnTeardown: true,
335
+ closeApiOnTeardown,
336
+ username,
337
+ password,
298
338
  });
299
339
 
300
340
  this.nativeRfcServers.set(streamKey, created);
@@ -303,7 +343,14 @@ export class StreamManager {
303
343
  if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
304
344
  });
305
345
 
306
- return { host: created.host, port: created.port, sdp: created.sdp, audio: created.audio };
346
+ return {
347
+ host: created.host,
348
+ port: created.port,
349
+ sdp: created.sdp,
350
+ audio: created.audio,
351
+ username: (created as any).username || this.opts.credentials.username,
352
+ password: (created as any).password || this.opts.credentials.password,
353
+ };
307
354
  })();
308
355
 
309
356
  this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
@@ -320,7 +367,26 @@ export class StreamManager {
320
367
  profile: StreamProfile,
321
368
  streamKey: string,
322
369
  expectedVideoType?: 'H264' | 'H265',
323
- ): Promise<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }> {
370
+ ): Promise<RfcServerInfo> {
324
371
  return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
325
372
  }
373
+
374
+ /**
375
+ * Close all active stream servers.
376
+ * Useful when the main connection is reset and streams need to be recreated.
377
+ */
378
+ async closeAllStreams(reason?: string): Promise<void> {
379
+ const servers = Array.from(this.nativeRfcServers.values());
380
+ this.nativeRfcServers.clear();
381
+
382
+ await Promise.allSettled(
383
+ servers.map(async (server) => {
384
+ try {
385
+ await server.close(reason || 'connection reset');
386
+ } catch (e) {
387
+ this.getLogger().debug('Error closing stream server', e);
388
+ }
389
+ })
390
+ );
391
+ }
326
392
  }
@@ -1,248 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Analyze Wireshark JSON exports to compare neolink vs scrypted UDP flows.
4
- """
5
- import json
6
- import sys
7
- import struct
8
- from typing import List, Dict, Any
9
-
10
- BCUDP_MAGIC_DISCOVERY = 0x3acf872a
11
- BCUDP_MAGIC_DATA = 0x10cf872a
12
- BCUDP_MAGIC_ACK = 0x20cf872a
13
-
14
- def hex_to_bytes(hex_str: str) -> bytes:
15
- """Convert hex string (e.g., '10:cf:87:2a') to bytes."""
16
- try:
17
- return bytes.fromhex(hex_str.replace(':', '').replace(' ', ''))
18
- except:
19
- return b''
20
-
21
- def parse_bcudp_packet(udp_data_hex: str) -> Dict[str, Any] | None:
22
- """Parse BCUDP packet from hex string."""
23
- try:
24
- data = hex_to_bytes(udp_data_hex)
25
- if len(data) < 4:
26
- return None
27
-
28
- # Wireshark exports hex strings in the order they appear in the packet
29
- # BCUDP magic numbers are stored as little-endian in the packet
30
- # But when we convert hex string to bytes, we get the bytes in order
31
- # So 3a:cf:87:2a becomes bytes [0x3a, 0xcf, 0x87, 0x2a]
32
- # Reading as little-endian: 0x2a87cf3a (wrong)
33
- # Reading as big-endian: 0x3acf872a (correct!)
34
- # Actually wait - let me check the actual byte order in the hex string
35
- # The hex shows "3a:cf:87:2a" which as bytes is [0x3a, 0xcf, 0x87, 0x2a]
36
- # As little-endian uint32: 0x2a87cf3a
37
- # As big-endian uint32: 0x3acf872a
38
- # So we need big-endian for the magic comparison
39
- magic = struct.unpack('>I', data[0:4])[0]
40
-
41
- if magic == BCUDP_MAGIC_DISCOVERY:
42
- return {'type': 'discovery', 'magic': hex(magic), 'payload': udp_data_hex}
43
- elif magic == BCUDP_MAGIC_DATA:
44
- if len(data) < 20:
45
- return None
46
- connection_id = int.from_bytes(data[4:8], 'little', signed=True)
47
- packet_id = int.from_bytes(data[12:16], 'little')
48
- payload_len = int.from_bytes(data[16:20], 'little')
49
- return {
50
- 'type': 'data',
51
- 'magic': hex(magic),
52
- 'connection_id': connection_id,
53
- 'packet_id': packet_id,
54
- 'payload_len': payload_len,
55
- 'payload_hex': udp_data_hex[:80] # First 40 bytes hex
56
- }
57
- elif magic == BCUDP_MAGIC_ACK:
58
- if len(data) < 28:
59
- return None
60
- connection_id = int.from_bytes(data[4:8], 'little', signed=True)
61
- group_id = int.from_bytes(data[12:16], 'little')
62
- packet_id = int.from_bytes(data[16:20], 'little')
63
- return {
64
- 'type': 'ack',
65
- 'magic': hex(magic),
66
- 'connection_id': connection_id,
67
- 'group_id': group_id,
68
- 'packet_id': packet_id,
69
- 'payload_hex': udp_data_hex[:56] # First 28 bytes hex
70
- }
71
- except Exception as e:
72
- return None
73
-
74
- return None
75
-
76
- def extract_packets(json_file: str) -> List[Dict[str, Any]]:
77
- """Extract BCUDP packets from Wireshark JSON export."""
78
- packets = []
79
-
80
- with open(json_file, 'r') as f:
81
- data = json.load(f)
82
-
83
- for entry in data:
84
- source = entry.get('_source', {})
85
- layers = source.get('layers', {})
86
-
87
- # Get frame info
88
- frame = layers.get('frame', {})
89
- frame_num = int(frame.get('frame.number', '0'))
90
- frame_time = float(frame.get('frame.time_relative', '0'))
91
-
92
- # Get UDP info
93
- udp = layers.get('udp', {})
94
- if not udp:
95
- continue
96
-
97
- src_port = int(udp.get('udp.srcport', '0'))
98
- dst_port = int(udp.get('udp.dstport', '0'))
99
-
100
- # Get UDP data (from udp.payload field)
101
- udp_data_hex = udp.get('udp.payload', '')
102
- if not udp_data_hex:
103
- continue
104
-
105
- # Get IP addresses to determine direction
106
- ip = layers.get('ip', {})
107
- src_ip = ip.get('ip.src', '')
108
- dst_ip = ip.get('ip.dst', '')
109
-
110
- # Parse BCUDP packet
111
- bcudp = parse_bcudp_packet(udp_data_hex)
112
- if bcudp:
113
- packets.append({
114
- 'frame_num': frame_num,
115
- 'time': frame_time,
116
- 'src_ip': src_ip,
117
- 'dst_ip': dst_ip,
118
- 'src_port': src_port,
119
- 'dst_port': dst_port,
120
- 'bcudp': bcudp
121
- })
122
-
123
- return packets
124
-
125
- def print_flow_summary(packets: List[Dict[str, Any]], name: str, limit: int = 100):
126
- """Print summary of packet flow."""
127
- print(f"\n{'='*100}")
128
- print(f"{name}: {len(packets)} BCUDP packets")
129
- print(f"{'='*100}")
130
-
131
- # Count by type
132
- discovery_count = sum(1 for p in packets if p['bcudp']['type'] == 'discovery')
133
- data_count = sum(1 for p in packets if p['bcudp']['type'] == 'data')
134
- ack_count = sum(1 for p in packets if p['bcudp']['type'] == 'ack')
135
-
136
- print(f"\nCounts: Discovery={discovery_count}, Data={data_count}, ACK={ack_count}")
137
-
138
- # Show first N packets
139
- print(f"\nFirst {min(limit, len(packets))} packets:")
140
- for i, pkt in enumerate(packets[:limit]):
141
- bcudp = pkt['bcudp']
142
- direction = '→' if '192.168' in pkt['dst_ip'] else '←'
143
- pkt_type = bcudp['type'].upper()
144
-
145
- info = [f"#{pkt['frame_num']:4d}", f"{pkt['time']:8.3f}s", direction, pkt_type]
146
-
147
- if pkt_type == 'DATA':
148
- info.append(f"conn={bcudp.get('connection_id', '?')}")
149
- info.append(f"pid={bcudp.get('packet_id', '?')}")
150
- info.append(f"len={bcudp.get('payload_len', '?')}")
151
- elif pkt_type == 'ACK':
152
- info.append(f"conn={bcudp.get('connection_id', '?')}")
153
- info.append(f"pid={bcudp.get('packet_id', '?')}")
154
- info.append(f"gid={bcudp.get('group_id', '?')}")
155
-
156
- info.append(f"{pkt['src_port']}→{pkt['dst_port']}")
157
-
158
- print(' '.join(info))
159
-
160
- if i < 20: # Show hex for first 20 packets
161
- if 'payload_hex' in bcudp:
162
- print(f" Hex: {bcudp['payload_hex']}")
163
-
164
- def compare_initial_sequence(neolink_packets: List[Dict[str, Any]], scrypted_packets: List[Dict[str, Any]]):
165
- """Compare the initial sequence after discovery."""
166
- print(f"\n{'='*100}")
167
- print("INITIAL SEQUENCE COMPARISON (first 50 packets after discovery)")
168
- print(f"{'='*100}")
169
-
170
- # Find first discovery completion (look for D2C_C_R response)
171
- nl_discovery_end = None
172
- for i, pkt in enumerate(neolink_packets):
173
- if pkt['bcudp']['type'] == 'discovery':
174
- # Check if it's a response (coming from camera)
175
- if '192.168' in pkt['src_ip']: # From camera
176
- nl_discovery_end = i
177
- break
178
-
179
- sc_discovery_end = None
180
- for i, pkt in enumerate(scrypted_packets):
181
- if pkt['bcudp']['type'] == 'discovery':
182
- if '192.168' in pkt['src_ip']: # From camera
183
- sc_discovery_end = i
184
- break
185
-
186
- print(f"\nDiscovery completed at:")
187
- print(f" Neolink: packet #{nl_discovery_end}" if nl_discovery_end else " Neolink: not found")
188
- print(f" Scrypted: packet #{sc_discovery_end}" if sc_discovery_end else " Scrypted: not found")
189
-
190
- # Get next 50 packets after discovery
191
- nl_next = neolink_packets[nl_discovery_end+1:nl_discovery_end+51] if nl_discovery_end else []
192
- sc_next = scrypted_packets[sc_discovery_end+1:sc_discovery_end+51] if sc_discovery_end else []
193
-
194
- print(f"\nNext 50 packets after discovery:")
195
- print(f" Neolink: {len(nl_next)} packets")
196
- print(f" Scrypted: {len(sc_next)} packets")
197
-
198
- print(f"\nNeolink sequence:")
199
- for i, pkt in enumerate(nl_next[:30]):
200
- bcudp = pkt['bcudp']
201
- direction = '→' if '192.168' in pkt['dst_ip'] else '←'
202
- pkt_type = bcudp['type'].upper()
203
- time_rel = pkt['time'] - (neolink_packets[nl_discovery_end]['time'] if nl_discovery_end else 0)
204
-
205
- info = [f"{time_rel:6.3f}s", direction, pkt_type]
206
- if pkt_type == 'DATA':
207
- info.append(f"pid={bcudp.get('packet_id', '?')}")
208
- elif pkt_type == 'ACK':
209
- info.append(f"pid={bcudp.get('packet_id', '?')}")
210
-
211
- print(' '.join(info))
212
-
213
- print(f"\nScrypted sequence:")
214
- for i, pkt in enumerate(sc_next[:30]):
215
- bcudp = pkt['bcudp']
216
- direction = '→' if '192.168' in pkt['dst_ip'] else '←'
217
- pkt_type = bcudp['type'].upper()
218
- time_rel = pkt['time'] - (scrypted_packets[sc_discovery_end]['time'] if sc_discovery_end else 0)
219
-
220
- info = [f"{time_rel:6.3f}s", direction, pkt_type]
221
- if pkt_type == 'DATA':
222
- info.append(f"pid={bcudp.get('packet_id', '?')}")
223
- elif pkt_type == 'ACK':
224
- info.append(f"pid={bcudp.get('packet_id', '?')}")
225
-
226
- print(' '.join(info))
227
-
228
- def main():
229
- if len(sys.argv) < 3:
230
- print("Usage: python3 analyze_json.py <neolink.json> <scrypted.json>")
231
- sys.exit(1)
232
-
233
- neolink_file = sys.argv[1]
234
- scrypted_file = sys.argv[2]
235
-
236
- print("Loading neolink.json...")
237
- neolink_packets = extract_packets(neolink_file)
238
-
239
- print("Loading scrypted.json...")
240
- scrypted_packets = extract_packets(scrypted_file)
241
-
242
- print_flow_summary(neolink_packets, "NEOLINK", limit=50)
243
- print_flow_summary(scrypted_packets, "SCRYPTED", limit=50)
244
- compare_initial_sequence(neolink_packets, scrypted_packets)
245
-
246
- if __name__ == '__main__':
247
- main()
248
-
@@ -1,135 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Simple PCAP analyzer to compare neolink vs scrypted UDP flows"""
3
- import sys
4
- import struct
5
-
6
- def read_pcap_header(f):
7
- """Read PCAP file header (24 bytes)"""
8
- magic = f.read(4)
9
- if magic != b'\xd4\xc3\xb2\xa1': # pcap magic number (little endian)
10
- return None
11
- version_major, version_minor = struct.unpack('<HH', f.read(4))
12
- thiszone, sigfigs = struct.unpack('<II', f.read(8))
13
- snaplen, network = struct.unpack('<II', f.read(8))
14
- return {
15
- 'version_major': version_major,
16
- 'version_minor': version_minor,
17
- 'snaplen': snaplen,
18
- 'network': network
19
- }
20
-
21
- def read_packet_header(f):
22
- """Read PCAP packet header (16 bytes)"""
23
- data = f.read(16)
24
- if len(data) < 16:
25
- return None
26
- ts_sec, ts_usec, incl_len, orig_len = struct.unpack('<IIII', data)
27
- return {
28
- 'ts_sec': ts_sec,
29
- 'ts_usec': ts_usec,
30
- 'incl_len': incl_len,
31
- 'orig_len': orig_len
32
- }
33
-
34
- def analyze_pcap(filename):
35
- """Basic analysis of UDP packets in PCAP"""
36
- with open(filename, 'rb') as f:
37
- header = read_pcap_header(f)
38
- if not header:
39
- print(f"Error: {filename} is not a valid PCAP file")
40
- return
41
-
42
- packets = []
43
- packet_num = 0
44
-
45
- while True:
46
- pkt_header = read_packet_header(f)
47
- if not pkt_header:
48
- break
49
-
50
- packet_data = f.read(pkt_header['incl_len'])
51
- if len(packet_data) < pkt_header['incl_len']:
52
- break
53
-
54
- # Check if this is an Ethernet frame with IPv4 UDP
55
- if len(packet_data) >= 42: # Min Ethernet + IP + UDP headers
56
- # Skip Ethernet header (14 bytes), check IP
57
- ip_header = packet_data[14:34]
58
- if ip_header[0] == 0x45: # IPv4
59
- protocol = ip_header[9]
60
- if protocol == 17: # UDP
61
- src_port = struct.unpack('>H', ip_header[20:22])[0]
62
- dst_port = struct.unpack('>H', ip_header[22:24])[0]
63
- udp_data = packet_data[42:]
64
-
65
- # Check for BCUDP magic numbers
66
- if len(udp_data) >= 4:
67
- magic = udp_data[:4]
68
- if magic == b'\x3a\xcf\x87\x2a': # Discovery
69
- packets.append({
70
- 'num': packet_num,
71
- 'ts': pkt_header['ts_sec'] + pkt_header['ts_usec'] / 1000000,
72
- 'src_port': src_port,
73
- 'dst_port': dst_port,
74
- 'type': 'discovery',
75
- 'data': udp_data[:100] # First 100 bytes
76
- })
77
- elif magic == b'\x20\xcf\x87\x2a': # ACK
78
- packets.append({
79
- 'num': packet_num,
80
- 'ts': pkt_header['ts_sec'] + pkt_header['ts_usec'] / 1000000,
81
- 'src_port': src_port,
82
- 'dst_port': dst_port,
83
- 'type': 'ack',
84
- 'data': udp_data[:50]
85
- })
86
- elif magic == b'\x10\xcf\x87\x2a': # DATA
87
- packets.append({
88
- 'num': packet_num,
89
- 'ts': pkt_header['ts_sec'] + pkt_header['ts_usec'] / 1000000,
90
- 'src_port': src_port,
91
- 'dst_port': dst_port,
92
- 'type': 'data',
93
- 'data': udp_data[:50]
94
- })
95
-
96
- packet_num += 1
97
-
98
- print(f"\n=== {filename} ===")
99
- print(f"Total packets analyzed: {packet_num}")
100
- print(f"BCUDP packets found: {len(packets)}")
101
-
102
- # Group by type
103
- by_type = {}
104
- for pkt in packets:
105
- by_type.setdefault(pkt['type'], []).append(pkt)
106
-
107
- for pkt_type, pkts in by_type.items():
108
- print(f"\n{pkt_type.upper()}: {len(pkts)} packets")
109
- if pkts:
110
- print(f" First at {pkts[0]['ts']:.3f}s, last at {pkts[-1]['ts']:.3f}s")
111
- print(f" Ports: src={set(p['src_port'] for p in pkts)}, dst={set(p['dst_port'] for p in pkts)}")
112
-
113
- # Show first few discovery packets in detail
114
- discovery_pkts = [p for p in packets if p['type'] == 'discovery']
115
- if discovery_pkts:
116
- print(f"\nFirst 5 discovery packets:")
117
- for pkt in discovery_pkts[:5]:
118
- print(f" #{pkt['num']} at {pkt['ts']:.3f}s: {pkt['src_port']} -> {pkt['dst_port']}")
119
- # Look for XML-like content
120
- data_str = pkt['data'].decode('latin1', errors='ignore')
121
- if 'C2D' in data_str or 'D2C' in data_str:
122
- # Try to extract XML tag
123
- start = data_str.find('<')
124
- if start >= 0:
125
- end = data_str.find('>', start)
126
- if end > start:
127
- tag = data_str[start:end+1]
128
- print(f" Tag: {tag}")
129
-
130
- return packets
131
-
132
- if __name__ == '__main__':
133
- neolink_packets = analyze_pcap('neolink.pcapng')
134
- print("\n" + "="*60)
135
- scrypted_packets = analyze_pcap('scrypted.pcapng')