@apocaliss92/scrypted-reolink-native 0.1.1 → 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.
@@ -7,7 +7,7 @@ import sdk, {
7
7
  import {
8
8
  CommonCameraMixin,
9
9
  } from "./common";
10
- import { createBaichuanApi, normalizeUid } from "./connect";
10
+ import { DebugLogOption } from "./debug-options";
11
11
  import type ReolinkNativePlugin from "./main";
12
12
 
13
13
  export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
@@ -25,7 +25,7 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
25
25
 
26
26
  private isBatteryInfoLoggingEnabled(): boolean {
27
27
  const debugLogs = this.storageSettings.values.debugLogs || [];
28
- return debugLogs.includes('batteryInfo');
28
+ return debugLogs.includes(DebugLogOption.BatteryInfo);
29
29
  }
30
30
 
31
31
  constructor(nativeId: string, public plugin: ReolinkNativePlugin) {
@@ -289,6 +289,14 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
289
289
  async resetBaichuanClient(reason?: any): Promise<void> {
290
290
  try {
291
291
  this.unsubscribedToEvents?.();
292
+
293
+ // Close all stream servers before closing the main connection
294
+ // This ensures streams are properly cleaned up when using shared connection
295
+ if (this.streamManager) {
296
+ const reasonStr = reason?.message || reason?.toString?.() || 'connection reset';
297
+ await this.streamManager.closeAllStreams(reasonStr);
298
+ }
299
+
292
300
  await this.baichuanApi?.close();
293
301
  }
294
302
  catch (e) {
@@ -324,30 +332,8 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
324
332
  }
325
333
 
326
334
  async createStreamClient(): Promise<ReolinkBaichuanApi> {
327
- const { ipAddress, username, password, uid } = this.storageSettings.values;
328
- if (!ipAddress || !username || !password) {
329
- throw new Error('Missing camera credentials');
330
- }
331
- const normalizedUid = normalizeUid(uid);
332
- if (!normalizedUid) throw new Error("UID is required for battery cameras (BCUDP)");
333
-
334
- const debugOptions = this.getBaichuanDebugOptions();
335
- const api = await createBaichuanApi(
336
- {
337
- inputs: {
338
- host: ipAddress,
339
- username,
340
- password,
341
- uid: normalizedUid,
342
- logger: this.console,
343
- ...(debugOptions ? { debugOptions } : {}),
344
- },
345
- transport: 'udp',
346
- logger: this.console,
347
- }
348
- );
349
- await api.login();
350
-
351
- return api;
335
+ // Reuse the main Baichuan client connection instead of creating a new one
336
+ // This ensures we use a single session for everything (general + streams)
337
+ return await this.ensureClient();
352
338
  }
353
339
  }
package/src/camera.ts CHANGED
@@ -95,7 +95,6 @@ export class ReolinkNativeCamera extends CommonCameraMixin {
95
95
 
96
96
  async init() {
97
97
  this.startPeriodicTasks();
98
- // Align auxiliary devices state on init
99
98
  await this.alignAuxDevicesState();
100
99
  }
101
100
 
@@ -114,7 +113,7 @@ export class ReolinkNativeCamera extends CommonCameraMixin {
114
113
  username,
115
114
  password,
116
115
  logger: this.console,
117
- ...(debugOptions ? { debugOptions } : {}),
116
+ debugOptions
118
117
  },
119
118
  transport: 'tcp',
120
119
  logger: this.console,
package/src/common.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { DeviceCapabilities, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, MediaStreamUrl, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, RequestMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, VideoCamera, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
3
- import { StorageSettings, StorageSettingsDict } from "@scrypted/sdk/storage-settings";
2
+ import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, MediaStreamUrl, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, RequestMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
3
+ import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
4
  import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
5
5
  import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
6
+ import { convertDebugLogsToApiOptions, DebugLogOption, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
6
7
  import { ReolinkBaichuanIntercom } from "./intercom";
7
8
  import ReolinkNativePlugin from "./main";
8
9
  import { ReolinkPtzPresets } from "./presets";
@@ -268,17 +269,36 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
268
269
  combobox: true,
269
270
  immediate: true,
270
271
  defaultValue: [],
271
- choices: ['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'traceEvents', 'debugH264', 'debugParamSets', 'eventLogs', 'batteryInfo'],
272
+ choices: getDebugLogChoices(),
272
273
  onPut: async (ov, value) => {
273
- // Only reconnect if Baichuan-client flags changed; toggling event logs should be immediate.
274
- const oldSel = new Set(ov);
275
- const newSel = new Set(value);
276
- oldSel.delete('eventLogs');
277
- newSel.delete('eventLogs');
274
+ const oldApiOptions = getApiRelevantDebugLogs(ov || []);
275
+ const newApiOptions = getApiRelevantDebugLogs(value || []);
276
+
277
+ const oldSel = new Set(oldApiOptions);
278
+ const newSel = new Set(newApiOptions);
278
279
 
279
280
  const changed = oldSel.size !== newSel.size || Array.from(oldSel).some((k) => !newSel.has(k));
280
281
  if (changed && this.resetBaichuanClient) {
281
- await this.resetBaichuanClient('debugLogs changed');
282
+ // Clear any existing timeout
283
+ if (this.debugLogsResetTimeout) {
284
+ clearTimeout(this.debugLogsResetTimeout);
285
+ this.debugLogsResetTimeout = undefined;
286
+ }
287
+
288
+ // Defer reset by 2 seconds to allow settings to settle
289
+ this.debugLogsResetTimeout = setTimeout(async () => {
290
+ this.debugLogsResetTimeout = undefined;
291
+ try {
292
+ await this.resetBaichuanClient('debugLogs changed');
293
+ // Force reconnection with new debug options
294
+ this.baichuanApi = undefined;
295
+ this.ensureClientPromise = undefined;
296
+ // Trigger reconnection
297
+ await this.ensureClient();
298
+ } catch (e) {
299
+ this.getLogger().warn('Failed to reset client after debug logs change', e);
300
+ }
301
+ }, 2000);
282
302
  }
283
303
  },
284
304
  },
@@ -461,6 +481,7 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
461
481
  protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
462
482
  protected connectionTime: number | undefined;
463
483
  protected readonly protocol: BaichuanTransport;
484
+ private debugLogsResetTimeout: NodeJS.Timeout | undefined;
464
485
 
465
486
  // Abstract init method that subclasses must implement
466
487
  abstract init(): Promise<void>;
@@ -484,6 +505,8 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
484
505
  username: this.storageSettings.values.username || '',
485
506
  password: this.storageSettings.values.password || '',
486
507
  },
508
+ // For battery cameras, we use a shared connection
509
+ sharedConnection: options.type === 'battery',
487
510
  });
488
511
 
489
512
  setTimeout(async () => {
@@ -509,17 +532,8 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
509
532
  }
510
533
 
511
534
  getBaichuanDebugOptions(): any | undefined {
512
- const sel = new Set<string>(this.storageSettings.values.debugLogs);
513
- if (!sel.size) return undefined;
514
-
515
- const debugOptions: any = {};
516
- // Only pass through Baichuan client debug flags.
517
- const clientKeys = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'traceEvents', 'debugH264', 'debugParamSets']);
518
- for (const k of sel) {
519
- if (!clientKeys.has(k)) continue;
520
- debugOptions[k] = true;
521
- }
522
- return Object.keys(debugOptions).length ? debugOptions : undefined;
535
+ const debugLogs = this.storageSettings.values.debugLogs || [];
536
+ return convertDebugLogsToApiOptions(debugLogs);
523
537
  }
524
538
 
525
539
  isRecoverableBaichuanError(e: any): boolean {
@@ -564,12 +578,12 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
564
578
  try {
565
579
  const logger = this.getLogger();
566
580
 
567
- if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
581
+ if (this.isEventLogsEnabled()) {
568
582
  logger.log(`Baichuan event: ${JSON.stringify(ev)}`);
569
583
  }
570
584
 
571
585
  if (!this.isEventDispatchEnabled()) {
572
- if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
586
+ if (this.isEventLogsEnabled()) {
573
587
  logger.debug('Event dispatch is disabled, ignoring event');
574
588
  }
575
589
  return;
@@ -577,7 +591,7 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
577
591
 
578
592
  const channel = this.getRtspChannel();
579
593
  if (ev?.channel !== undefined && ev.channel !== channel) {
580
- if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
594
+ if (this.isEventLogsEnabled()) {
581
595
  logger.debug(`Event channel ${ev.channel} does not match camera channel ${channel}, ignoring`);
582
596
  }
583
597
  return;
@@ -589,7 +603,7 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
589
603
  switch (ev?.type) {
590
604
  case 'motion':
591
605
  motion = true;
592
- if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
606
+ if (this.isEventLogsEnabled()) {
593
607
  logger.log(`Motion event received (may be PIR or MD)`);
594
608
  }
595
609
  break;
@@ -607,7 +621,7 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
607
621
  motion = true;
608
622
  break;
609
623
  default:
610
- if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
624
+ if (this.isEventLogsEnabled()) {
611
625
  logger.debug(`Unknown event type: ${ev?.type}`);
612
626
  }
613
627
  return;
@@ -863,6 +877,11 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
863
877
  }
864
878
  }
865
879
 
880
+ isEventLogsEnabled(): boolean {
881
+ const debugLogs = this.storageSettings.values.debugLogs || [];
882
+ return debugLogs.includes(DebugLogOption.EventLogs);
883
+ }
884
+
866
885
  // BinarySensor interface implementation (for doorbell)
867
886
  handleDoorbellEvent(): void {
868
887
  if (!this.doorbellBinaryTimeout) {
@@ -1394,7 +1413,7 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
1394
1413
  password,
1395
1414
  uid: normalizedUid,
1396
1415
  logger: this.console,
1397
- ...(debugOptions ? { debugOptions } : {}),
1416
+ debugOptions,
1398
1417
  },
1399
1418
  transport: this.protocol,
1400
1419
  logger: this.console,
package/src/connect.ts CHANGED
@@ -8,7 +8,7 @@ export type BaichuanConnectInputs = {
8
8
  password: string;
9
9
  uid?: string;
10
10
  logger?: Console;
11
- debugOptions?: unknown;
11
+ debugOptions?: BaichuanClientOptions['debugOptions'];
12
12
  };
13
13
 
14
14
  export function normalizeUid(uid?: string): string | undefined {
@@ -52,7 +52,7 @@ export async function createBaichuanApi(props: {
52
52
  username: inputs.username,
53
53
  password: inputs.password,
54
54
  logger: inputs.logger,
55
- ...(inputs.debugOptions ? { debugOptions: inputs.debugOptions } : {}),
55
+ debugOptions: inputs.debugOptions ?? {}
56
56
  };
57
57
 
58
58
  const attachErrorHandler = (api: ReolinkBaichuanApi) => {
@@ -86,6 +86,8 @@ export async function createBaichuanApi(props: {
86
86
  }
87
87
  };
88
88
 
89
+ logger.log('Connecting with options:', JSON.stringify(base, null, 2));
90
+
89
91
  if (transport === "tcp") {
90
92
  const api = new ReolinkBaichuanApi({
91
93
  ...base,
@@ -104,6 +106,7 @@ export async function createBaichuanApi(props: {
104
106
  ...base,
105
107
  transport: "udp",
106
108
  uid,
109
+ idleDisconnect: true,
107
110
  });
108
111
  attachErrorHandler(api);
109
112
  return api;
@@ -0,0 +1,105 @@
1
+ import type { DebugOptions } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+
3
+ /**
4
+ * User-friendly debug log options enum
5
+ */
6
+ export enum DebugLogOption {
7
+ /** General debug logs */
8
+ General = 'general',
9
+ /** RTSP proxy/server debug logs */
10
+ DebugRtsp = 'debugRtsp',
11
+ /** Stream command tracing */
12
+ TraceStream = 'traceStream',
13
+ /** Talkback tracing */
14
+ TraceTalk = 'traceTalk',
15
+ /** Event tracing */
16
+ TraceEvents = 'traceEvents',
17
+ /** H.264 debug logs */
18
+ DebugH264 = 'debugH264',
19
+ /** SPS/PPS parameter sets debug logs */
20
+ DebugParamSets = 'debugParamSets',
21
+ /** Event logs (plugin-specific, not passed to API) */
22
+ EventLogs = 'eventLogs',
23
+ /** Battery info logs (plugin-specific, not passed to API) */
24
+ BatteryInfo = 'batteryInfo',
25
+ }
26
+
27
+ /**
28
+ * Maps user-friendly enum values to API DebugOptions keys
29
+ */
30
+ export function mapDebugLogToApiOption(option: DebugLogOption): keyof DebugOptions | null {
31
+ const mapping: Record<DebugLogOption, keyof DebugOptions | null> = {
32
+ [DebugLogOption.General]: 'general',
33
+ [DebugLogOption.DebugRtsp]: 'debugRtsp',
34
+ [DebugLogOption.TraceStream]: 'traceStream',
35
+ [DebugLogOption.TraceTalk]: 'traceTalk',
36
+ [DebugLogOption.TraceEvents]: 'traceEvents',
37
+ [DebugLogOption.DebugH264]: 'debugH264',
38
+ [DebugLogOption.DebugParamSets]: 'debugParamSets',
39
+ [DebugLogOption.EventLogs]: null, // Plugin-specific, not passed to API
40
+ [DebugLogOption.BatteryInfo]: null, // Plugin-specific, not passed to API
41
+ };
42
+ return mapping[option];
43
+ }
44
+
45
+ /**
46
+ * Convert array of DebugLogOption enum values to API DebugOptions
47
+ * Only includes options that are relevant to the API (excludes plugin-specific options)
48
+ */
49
+ export function convertDebugLogsToApiOptions(debugLogs: string[]): DebugOptions | undefined {
50
+ const apiOptions: DebugOptions = {};
51
+ const debugLogsSet = new Set(debugLogs);
52
+
53
+ // Iterate over enum values and build API options based on what's selected
54
+ for (const [key, friendlyName] of Object.entries(DebugLogDisplayNames)) {
55
+ if (debugLogsSet.has(friendlyName)) {
56
+ const apiKey = mapDebugLogToApiOption(key as DebugLogOption);
57
+ if (apiKey) {
58
+ apiOptions[apiKey] = true;
59
+ }
60
+ }
61
+ }
62
+
63
+ console.log(debugLogs, apiOptions);
64
+ return Object.keys(apiOptions).length > 0 ? apiOptions : undefined;
65
+ }
66
+
67
+ /**
68
+ * Get only the API-relevant debug log options (excludes plugin-specific options)
69
+ * Used to determine if reconnection is needed when debug options change
70
+ */
71
+ export function getApiRelevantDebugLogs(debugLogs: string[]): string[] {
72
+ return debugLogs.filter(log => {
73
+ const option = log as DebugLogOption;
74
+ const apiKey = mapDebugLogToApiOption(option);
75
+ // Only include options that map to API keys (exclude plugin-specific options)
76
+ return apiKey !== null;
77
+ });
78
+ }
79
+
80
+ /**
81
+ * User-friendly display names for debug log options
82
+ */
83
+ export const DebugLogDisplayNames: Record<DebugLogOption, string> = {
84
+ [DebugLogOption.General]: 'General',
85
+ [DebugLogOption.DebugRtsp]: 'RTSP',
86
+ [DebugLogOption.TraceStream]: 'Trace stream',
87
+ [DebugLogOption.TraceTalk]: 'Trace talk',
88
+ [DebugLogOption.TraceEvents]: 'Trace events XML',
89
+ [DebugLogOption.DebugH264]: 'H264',
90
+ [DebugLogOption.DebugParamSets]: 'Video param sets',
91
+ [DebugLogOption.EventLogs]: 'Object detection events',
92
+ [DebugLogOption.BatteryInfo]: 'Battery info update',
93
+ };
94
+
95
+ /**
96
+ * Get debug log choices with user-friendly names
97
+ * Returns array of strings in format "value=displayName" for Scrypted settings
98
+ */
99
+ export function getDebugLogChoices(): string[] {
100
+ return Object.values(DebugLogOption).map(option => {
101
+ const displayName = DebugLogDisplayNames[option];
102
+ return `${displayName}`;
103
+ });
104
+ }
105
+
@@ -28,6 +28,8 @@ export interface StreamManagerOptions {
28
28
  username: string;
29
29
  password: string;
30
30
  };
31
+ /** If true, the stream client is shared with the main connection. Default: false. */
32
+ sharedConnection?: boolean;
31
33
  }
32
34
 
33
35
  export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
@@ -321,13 +323,16 @@ export class StreamManager {
321
323
  // Use the same credentials as the main connection
322
324
  const { username, password } = this.opts.credentials;
323
325
 
326
+ // If connection is shared, don't close it when stream teardown happens
327
+ const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
328
+
324
329
  const created = await createScryptedRfc4571TcpServer({
325
330
  api,
326
331
  channel,
327
332
  profile,
328
333
  logger: this.getLogger(),
329
334
  expectedVideoType: expectedVideoType as VideoType | undefined,
330
- closeApiOnTeardown: true,
335
+ closeApiOnTeardown,
331
336
  username,
332
337
  password,
333
338
  });
@@ -365,4 +370,23 @@ export class StreamManager {
365
370
  ): Promise<RfcServerInfo> {
366
371
  return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
367
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
+ }
368
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
-