@apocaliss92/scrypted-reolink-native 0.1.1 → 0.1.3
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.
- package/.vscode/settings.json +1 -1
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/pcap/network_cam.txt +108321 -0
- package/pcap/wifi0ap0-2412MHz-UnifiRuocco.pcap +0 -0
- package/src/camera-battery.ts +13 -27
- package/src/camera.ts +1 -2
- package/src/common.ts +45 -26
- package/src/connect.ts +3 -2
- package/src/debug-options.ts +105 -0
- package/src/stream-utils.ts +25 -1
- package/pcap/analyze_json.py +0 -248
- package/pcap/analyze_pcap.py +0 -135
- package/pcap/compare_pcaps.py +0 -274
- package/pcap/compare_stream_flow.py +0 -222
- package/pcap/scrypted.json +0 -307560
- package/pcap/scrypted.pcapng +0 -0
- package/pcap/simple_compare.py +0 -178
|
Binary file
|
package/src/camera-battery.ts
CHANGED
|
@@ -7,7 +7,7 @@ import sdk, {
|
|
|
7
7
|
import {
|
|
8
8
|
CommonCameraMixin,
|
|
9
9
|
} from "./common";
|
|
10
|
-
import {
|
|
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(
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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,
|
|
3
|
-
import { StorageSettings
|
|
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:
|
|
272
|
+
choices: getDebugLogChoices(),
|
|
272
273
|
onPut: async (ov, value) => {
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
oldSel
|
|
277
|
-
newSel
|
|
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
|
-
|
|
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
|
|
513
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
55
|
+
debugOptions: inputs.debugOptions ?? {}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
const attachErrorHandler = (api: ReolinkBaichuanApi) => {
|
|
@@ -104,6 +104,7 @@ export async function createBaichuanApi(props: {
|
|
|
104
104
|
...base,
|
|
105
105
|
transport: "udp",
|
|
106
106
|
uid,
|
|
107
|
+
idleDisconnect: true,
|
|
107
108
|
});
|
|
108
109
|
attachErrorHandler(api);
|
|
109
110
|
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
|
+
|
package/src/stream-utils.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/pcap/analyze_json.py
DELETED
|
@@ -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
|
-
|