@apocaliss92/scrypted-reolink-native 0.0.3 → 0.0.4
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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera.ts +203 -352
- package/src/main.ts +11 -15
- package/src/presets.ts +16 -31
- package/src/utils.ts +49 -57
package/src/camera.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { BatteryInfo, DeviceCapabilities, PtzCommand, ReolinkBaichuanApi,
|
|
2
|
-
import sdk, { Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, Sleep, VideoCamera, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
|
|
1
|
+
import type { BatteryInfo, DebugOptions, DeviceCapabilities, PtzCommand, ReolinkBaichuanApi, StreamProfile } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, Sleep, VideoCamera, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
|
|
3
3
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
4
|
-
import { RtspClient } from "../../scrypted/common/src/rtsp-server";
|
|
5
4
|
import { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
5
|
+
import { BaichuanTransport, connectBaichuanWithTcpUdpFallback, createBaichuanApi, maskUid } from './connect';
|
|
6
6
|
import { ReolinkBaichuanIntercom } from "./intercom";
|
|
7
7
|
import ReolinkNativePlugin from "./main";
|
|
8
8
|
import { ReolinkPtzPresets } from "./presets";
|
|
9
9
|
import { parseStreamProfileFromId, StreamManager } from './stream-utils';
|
|
10
|
-
import {
|
|
10
|
+
import { getDeviceInterfaces } from "./utils";
|
|
11
11
|
|
|
12
12
|
export const moToB64 = async (mo: MediaObject) => {
|
|
13
13
|
const bufferImage = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
@@ -158,22 +158,19 @@ class ReolinkCameraPirSensor extends ScryptedDeviceBase implements OnOff {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// export class ReolinkNativeCamera extends ScryptedDeviceBase implements Camera, DeviceProvider, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays {
|
|
161
|
-
export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Camera, DeviceProvider, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays {
|
|
161
|
+
export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Camera, DeviceProvider, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays, BinarySensor {
|
|
162
162
|
videoStreamOptions: Promise<UrlMediaStreamOptions[]>;
|
|
163
163
|
motionTimeout: NodeJS.Timeout;
|
|
164
|
+
private doorbellBinaryTimeout: NodeJS.Timeout | undefined;
|
|
164
165
|
siren: ReolinkCameraSiren;
|
|
165
166
|
floodlight: ReolinkCameraFloodlight;
|
|
166
167
|
pirSensor: ReolinkCameraPirSensor;
|
|
167
168
|
private baichuanApi: ReolinkBaichuanApi | undefined;
|
|
169
|
+
private connectionTime: number | undefined;
|
|
168
170
|
private refreshingState = false;
|
|
169
171
|
|
|
170
|
-
private subscribedToEvents = false;
|
|
171
|
-
private onSimpleEvent: ((ev: ReolinkSimpleEvent) => void) | undefined;
|
|
172
|
-
private eventsApi: ReolinkBaichuanApi | undefined;
|
|
173
|
-
|
|
174
172
|
private periodicStarted = false;
|
|
175
173
|
private statusPollTimer: NodeJS.Timeout | undefined;
|
|
176
|
-
private eventsRestartTimer: NodeJS.Timeout | undefined;
|
|
177
174
|
private lastActivityMs = Date.now();
|
|
178
175
|
private lastB64Snapshot: string | undefined;
|
|
179
176
|
private lastSnapshotTaken: number | undefined;
|
|
@@ -181,13 +178,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
181
178
|
|
|
182
179
|
private udpFallbackAlerted = false;
|
|
183
180
|
|
|
184
|
-
private dispatchEventsApplyTimer: NodeJS.Timeout | undefined;
|
|
185
|
-
private dispatchEventsApplySeq = 0;
|
|
186
|
-
|
|
187
|
-
private lastAppliedDispatchEventsKey: string | undefined;
|
|
188
|
-
|
|
189
|
-
intercomClient: RtspClient;
|
|
190
|
-
|
|
191
181
|
private intercom: ReolinkBaichuanIntercom;
|
|
192
182
|
|
|
193
183
|
private ptzPresets: ReolinkPtzPresets;
|
|
@@ -229,7 +219,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
229
219
|
defaultValue: ['motion', 'objects'],
|
|
230
220
|
choices: ['motion', 'objects'],
|
|
231
221
|
onPut: async () => {
|
|
232
|
-
this.
|
|
222
|
+
await this.subscribeToEvents();
|
|
233
223
|
},
|
|
234
224
|
},
|
|
235
225
|
debugLogs: {
|
|
@@ -248,7 +238,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
248
238
|
oldSel.delete('eventLogs');
|
|
249
239
|
newSel.delete('eventLogs');
|
|
250
240
|
|
|
251
|
-
const changed = oldSel.size !== newSel.size
|
|
241
|
+
const changed = oldSel.size !== newSel.size;
|
|
252
242
|
if (changed) {
|
|
253
243
|
await this.resetBaichuanClient('debugLogs changed');
|
|
254
244
|
}
|
|
@@ -396,6 +386,11 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
396
386
|
type: 'boolean',
|
|
397
387
|
hide: true
|
|
398
388
|
},
|
|
389
|
+
useUdp: {
|
|
390
|
+
type: 'boolean',
|
|
391
|
+
hide: true,
|
|
392
|
+
defaultValue: false
|
|
393
|
+
},
|
|
399
394
|
intercomBlocksPerPayload: {
|
|
400
395
|
subgroup: 'Advanced',
|
|
401
396
|
title: 'Intercom Blocks Per Payload',
|
|
@@ -454,23 +449,15 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
454
449
|
|
|
455
450
|
private async resetBaichuanClient(reason?: any): Promise<void> {
|
|
456
451
|
try {
|
|
452
|
+
this.unsubscribedToEvents();
|
|
457
453
|
await this.baichuanApi?.close();
|
|
458
454
|
}
|
|
459
455
|
catch (e) {
|
|
460
456
|
this.getLogger().warn('Error closing Baichuan client during reset', e);
|
|
461
457
|
}
|
|
462
458
|
finally {
|
|
463
|
-
if (this.eventsApi && this.onSimpleEvent) {
|
|
464
|
-
try {
|
|
465
|
-
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
// ignore
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
459
|
this.baichuanApi = undefined;
|
|
472
|
-
this.
|
|
473
|
-
this.eventsApi = undefined;
|
|
460
|
+
this.connectionTime = undefined;
|
|
474
461
|
}
|
|
475
462
|
|
|
476
463
|
if (reason) {
|
|
@@ -512,9 +499,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
512
499
|
|
|
513
500
|
// Start event subscription after discovery.
|
|
514
501
|
try {
|
|
515
|
-
|
|
516
|
-
await this.ensureBaichuanEventSubscription();
|
|
517
|
-
}
|
|
502
|
+
await this.subscribeToEvents();
|
|
518
503
|
}
|
|
519
504
|
catch (e) {
|
|
520
505
|
logger.warn('Failed to subscribe to Baichuan events', e);
|
|
@@ -536,7 +521,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
536
521
|
return this.baichuanApi;
|
|
537
522
|
}
|
|
538
523
|
|
|
539
|
-
const { ipAddress, username, password, uid } = this.storageSettings.values;
|
|
524
|
+
const { ipAddress, username, password, uid, useUdp } = this.storageSettings.values;
|
|
540
525
|
|
|
541
526
|
if (!ipAddress || !username || !password) {
|
|
542
527
|
throw new Error('Missing camera credentials');
|
|
@@ -547,7 +532,29 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
547
532
|
}
|
|
548
533
|
|
|
549
534
|
const debugOptions = this.getBaichuanDebugOptions();
|
|
550
|
-
|
|
535
|
+
|
|
536
|
+
// Se useUdp è impostato, usa direttamente UDP senza provare TCP
|
|
537
|
+
if (useUdp) {
|
|
538
|
+
this.getLogger().log(`Using UDP transport directly (useUdp=${useUdp})`);
|
|
539
|
+
const api = await createBaichuanApi(
|
|
540
|
+
{
|
|
541
|
+
host: ipAddress,
|
|
542
|
+
username,
|
|
543
|
+
password,
|
|
544
|
+
uid,
|
|
545
|
+
logger: this.console,
|
|
546
|
+
...(debugOptions ? { debugOptions } : {}),
|
|
547
|
+
},
|
|
548
|
+
'udp',
|
|
549
|
+
);
|
|
550
|
+
await api.login();
|
|
551
|
+
this.baichuanApi = api;
|
|
552
|
+
this.connectionTime = Date.now();
|
|
553
|
+
return api;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Altrimenti prova TCP con fallback a UDP
|
|
557
|
+
const { api, transport } = await connectBaichuanWithTcpUdpFallback(
|
|
551
558
|
{
|
|
552
559
|
host: ipAddress,
|
|
553
560
|
username,
|
|
@@ -567,20 +574,45 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
567
574
|
},
|
|
568
575
|
);
|
|
569
576
|
|
|
577
|
+
// Se il fallback ha usato UDP, salva la setting per usare sempre UDP in futuro
|
|
578
|
+
if (transport === 'udp') {
|
|
579
|
+
await this.storageSettings.putSetting('useUdp', 'true');
|
|
580
|
+
this.getLogger().log(`TCP fallback to UDP detected. Saving useUdp setting for future connections.`);
|
|
581
|
+
}
|
|
582
|
+
|
|
570
583
|
this.baichuanApi = api;
|
|
584
|
+
this.connectionTime = Date.now();
|
|
571
585
|
return api;
|
|
572
586
|
}
|
|
573
587
|
|
|
574
|
-
private
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const transport = primary.client.getTransport();
|
|
588
|
+
private getBaichuanDebugOptions(): any | undefined {
|
|
589
|
+
const sel = new Set<string>(this.storageSettings.values.debugLogs);
|
|
590
|
+
if (!sel.size) return undefined;
|
|
578
591
|
|
|
579
|
-
const
|
|
592
|
+
const debugOptions: DebugOptions = {};
|
|
593
|
+
// Only pass through Baichuan client debug flags.
|
|
594
|
+
const clientKeys = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets']);
|
|
595
|
+
for (const k of sel) {
|
|
596
|
+
if (!clientKeys.has(k)) continue;
|
|
597
|
+
debugOptions[k] = true;
|
|
598
|
+
}
|
|
599
|
+
return Object.keys(debugOptions).length ? debugOptions : undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private async createStreamClient(): Promise<ReolinkBaichuanApi> {
|
|
603
|
+
const { ipAddress, username, password, uid, useUdp } = this.storageSettings.values;
|
|
580
604
|
if (!ipAddress || !username || !password) {
|
|
581
605
|
throw new Error('Missing camera credentials');
|
|
582
606
|
}
|
|
583
607
|
|
|
608
|
+
// Usa la setting useUdp per determinare il transport invece di ottenere dal client primario
|
|
609
|
+
// Questo garantisce che lo stream client usi lo stesso transport del client principale
|
|
610
|
+
const transport: BaichuanTransport = useUdp ? 'udp' : 'tcp';
|
|
611
|
+
|
|
612
|
+
if (useUdp) {
|
|
613
|
+
this.getLogger().log(`Creating stream client with UDP transport (useUdp=${useUdp})`);
|
|
614
|
+
}
|
|
615
|
+
|
|
584
616
|
const debugOptions = this.getBaichuanDebugOptions();
|
|
585
617
|
const api = await createBaichuanApi(
|
|
586
618
|
{
|
|
@@ -612,9 +644,37 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
612
644
|
const channel = this.getRtspChannel();
|
|
613
645
|
|
|
614
646
|
try {
|
|
615
|
-
const { capabilities, abilities, support, presets } = await
|
|
647
|
+
const { capabilities, abilities, support, presets } = await this.withBaichuanRetry(async () =>
|
|
648
|
+
api.getDeviceCapabilities(channel, {
|
|
649
|
+
probeAi: false,
|
|
650
|
+
})
|
|
651
|
+
);
|
|
616
652
|
this.storageSettings.values.capabilities = capabilities;
|
|
617
653
|
this.ptzPresets.setCachedPtzPresets(presets);
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const interfaces = getDeviceInterfaces({
|
|
658
|
+
capabilities,
|
|
659
|
+
logger: this.console,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const device: Device = {
|
|
663
|
+
nativeId: this.nativeId,
|
|
664
|
+
providerNativeId: this.plugin.nativeId,
|
|
665
|
+
name: this.name,
|
|
666
|
+
interfaces,
|
|
667
|
+
type: this.type as ScryptedDeviceType,
|
|
668
|
+
info: this.info,
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
|
|
672
|
+
|
|
673
|
+
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
logger.error('Failed to update device interfaces', e);
|
|
676
|
+
}
|
|
677
|
+
|
|
618
678
|
this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
|
|
619
679
|
}
|
|
620
680
|
catch (e) {
|
|
@@ -628,177 +688,93 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
628
688
|
logger.error('Failed to refresh device status', e);
|
|
629
689
|
}
|
|
630
690
|
|
|
631
|
-
try {
|
|
632
|
-
const interfaces = await this.getDeviceInterfaces();
|
|
633
|
-
|
|
634
|
-
const device: Device = {
|
|
635
|
-
nativeId: this.nativeId,
|
|
636
|
-
providerNativeId: this.plugin.nativeId,
|
|
637
|
-
name: this.name,
|
|
638
|
-
interfaces,
|
|
639
|
-
type: this.type as ScryptedDeviceType,
|
|
640
|
-
info: this.info,
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
|
|
644
|
-
|
|
645
|
-
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
646
|
-
} catch (e) {
|
|
647
|
-
logger.error('Failed to update device interfaces', e);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
691
|
this.refreshingState = false;
|
|
651
692
|
}
|
|
652
693
|
|
|
653
|
-
private
|
|
654
|
-
if (!this.isEventDispatchEnabled()) {
|
|
655
|
-
await this.disableBaichuanEventSubscription();
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
if (this.subscribedToEvents) return;
|
|
659
|
-
const api = await this.ensureClient();
|
|
660
|
-
|
|
694
|
+
private onSimpleEvent = (ev: any) => {
|
|
661
695
|
try {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
// Some firmwares don't require explicit subscribe or may reject it.
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
this.onSimpleEvent ||= (ev: any) => {
|
|
669
|
-
try {
|
|
670
|
-
if (!this.isEventDispatchEnabled()) return;
|
|
671
|
-
if (this.isEventLogsEnabled()) {
|
|
672
|
-
this.getLogger().debug(`Baichuan event: ${JSON.stringify(ev)}`);
|
|
673
|
-
}
|
|
674
|
-
const channel = this.getRtspChannel();
|
|
675
|
-
if (ev?.channel !== undefined && ev.channel !== channel) return;
|
|
676
|
-
|
|
677
|
-
const objects: string[] = [];
|
|
678
|
-
let motion = false;
|
|
679
|
-
|
|
680
|
-
switch (ev?.type) {
|
|
681
|
-
case 'motion':
|
|
682
|
-
motion = this.shouldDispatchMotion();
|
|
683
|
-
break;
|
|
684
|
-
case 'doorbell':
|
|
685
|
-
// Placeholder: treat doorbell as motion.
|
|
686
|
-
motion = this.shouldDispatchMotion();
|
|
687
|
-
break;
|
|
688
|
-
case 'people':
|
|
689
|
-
case 'vehicle':
|
|
690
|
-
case 'animal':
|
|
691
|
-
case 'face':
|
|
692
|
-
case 'package':
|
|
693
|
-
case 'other':
|
|
694
|
-
if (this.shouldDispatchObjects()) objects.push(ev.type);
|
|
695
|
-
break;
|
|
696
|
-
default:
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
this.processEvents({ motion, objects }).catch(() => { });
|
|
696
|
+
if (!this.isEventDispatchEnabled()) return;
|
|
697
|
+
if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
|
|
698
|
+
this.getLogger().debug(`Baichuan event: ${JSON.stringify(ev)}`);
|
|
701
699
|
}
|
|
702
|
-
|
|
703
|
-
|
|
700
|
+
const channel = this.getRtspChannel();
|
|
701
|
+
if (ev?.channel !== undefined && ev.channel !== channel) return;
|
|
702
|
+
|
|
703
|
+
const objects: string[] = [];
|
|
704
|
+
let motion = false;
|
|
705
|
+
|
|
706
|
+
switch (ev?.type) {
|
|
707
|
+
case 'motion':
|
|
708
|
+
motion = this.shouldDispatchMotion();
|
|
709
|
+
break;
|
|
710
|
+
case 'doorbell':
|
|
711
|
+
this.binaryState = true;
|
|
712
|
+
if (this.doorbellBinaryTimeout) clearTimeout(this.doorbellBinaryTimeout);
|
|
713
|
+
this.doorbellBinaryTimeout = setTimeout(() => {
|
|
714
|
+
this.binaryState = false;
|
|
715
|
+
this.doorbellBinaryTimeout = undefined;
|
|
716
|
+
}, 2000);
|
|
717
|
+
|
|
718
|
+
motion = this.shouldDispatchMotion();
|
|
719
|
+
break;
|
|
720
|
+
case 'people':
|
|
721
|
+
case 'vehicle':
|
|
722
|
+
case 'animal':
|
|
723
|
+
case 'face':
|
|
724
|
+
case 'package':
|
|
725
|
+
case 'other':
|
|
726
|
+
if (this.shouldDispatchObjects()) objects.push(ev.type);
|
|
727
|
+
break;
|
|
728
|
+
default:
|
|
729
|
+
return;
|
|
704
730
|
}
|
|
705
|
-
};
|
|
706
731
|
|
|
707
|
-
|
|
708
|
-
if (this.eventsApi && this.eventsApi !== api && this.onSimpleEvent) {
|
|
709
|
-
try {
|
|
710
|
-
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
711
|
-
}
|
|
712
|
-
catch {
|
|
713
|
-
// ignore
|
|
714
|
-
}
|
|
732
|
+
this.processEvents({ motion, objects }).catch(() => { });
|
|
715
733
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
this.eventsApi = api;
|
|
734
|
+
catch {
|
|
735
|
+
// ignore
|
|
719
736
|
}
|
|
720
|
-
|
|
721
|
-
this.subscribedToEvents = true;
|
|
722
737
|
}
|
|
723
738
|
|
|
724
|
-
private
|
|
725
|
-
// Do not wake up battery cameras / do not force login: best-effort cleanup only.
|
|
739
|
+
private unsubscribedToEvents() {
|
|
726
740
|
const api = this.getClient();
|
|
727
|
-
|
|
728
|
-
try {
|
|
729
|
-
await api.unsubscribeEvents();
|
|
730
|
-
}
|
|
731
|
-
catch {
|
|
732
|
-
// ignore
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (this.eventsApi && this.onSimpleEvent) {
|
|
737
|
-
try {
|
|
738
|
-
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
739
|
-
}
|
|
740
|
-
catch {
|
|
741
|
-
// ignore
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
this.subscribedToEvents = false;
|
|
746
|
-
this.eventsApi = undefined;
|
|
747
|
-
|
|
748
|
-
if (this.motionTimeout) {
|
|
749
|
-
clearTimeout(this.motionTimeout);
|
|
750
|
-
}
|
|
751
|
-
this.motionDetected = false;
|
|
741
|
+
api.offSimpleEvent(this.onSimpleEvent);
|
|
752
742
|
}
|
|
753
743
|
|
|
754
|
-
private async
|
|
744
|
+
private async subscribeToEvents(): Promise<void> {
|
|
755
745
|
const logger = this.getLogger();
|
|
756
746
|
const selection = Array.from(this.getDispatchEventsSelection()).sort();
|
|
757
|
-
const
|
|
758
|
-
const prevKey = this.lastAppliedDispatchEventsKey;
|
|
747
|
+
const enabled = selection.length > 0;
|
|
759
748
|
|
|
760
|
-
|
|
761
|
-
logger.log(`Dispatch Events changed: ${selection.length ? selection.join(', ') : '(disabled)'}`);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// User-initiated settings change counts as activity.
|
|
749
|
+
// Settings change / init counts as activity.
|
|
765
750
|
this.markActivity();
|
|
766
751
|
|
|
767
|
-
// Empty selection disables everything.
|
|
768
|
-
if (!this.isEventDispatchEnabled()) {
|
|
769
|
-
if (this.subscribedToEvents) {
|
|
770
|
-
logger.log('Event listener stopped (Dispatch Events disabled)');
|
|
771
|
-
}
|
|
772
|
-
await this.disableBaichuanEventSubscription();
|
|
773
|
-
this.lastAppliedDispatchEventsKey = key;
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// If motion is not selected, ensure state is cleared.
|
|
778
752
|
if (!this.shouldDispatchMotion()) {
|
|
779
753
|
if (this.motionTimeout) clearTimeout(this.motionTimeout);
|
|
780
754
|
this.motionDetected = false;
|
|
781
755
|
}
|
|
782
756
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if (
|
|
786
|
-
|
|
787
|
-
|
|
757
|
+
this.unsubscribedToEvents();
|
|
758
|
+
|
|
759
|
+
if (!enabled) {
|
|
760
|
+
if (this.doorbellBinaryTimeout) {
|
|
761
|
+
clearTimeout(this.doorbellBinaryTimeout);
|
|
762
|
+
this.doorbellBinaryTimeout = undefined;
|
|
763
|
+
}
|
|
764
|
+
this.binaryState = false;
|
|
788
765
|
return;
|
|
789
766
|
}
|
|
790
767
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
this.
|
|
768
|
+
const api = await this.ensureClient();
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
api.onSimpleEvent(this.onSimpleEvent);
|
|
772
|
+
logger.log(`Subscribed to events (${selection.join(', ')})`);
|
|
773
|
+
}
|
|
774
|
+
catch (e) {
|
|
775
|
+
logger.warn('Failed to attach Baichuan event handler', e);
|
|
795
776
|
return;
|
|
796
777
|
}
|
|
797
|
-
|
|
798
|
-
logger.log(`Event listener restarting (${selection.join(', ')})`);
|
|
799
|
-
await this.disableBaichuanEventSubscription();
|
|
800
|
-
await this.ensureBaichuanEventSubscription();
|
|
801
|
-
this.lastAppliedDispatchEventsKey = key;
|
|
802
778
|
}
|
|
803
779
|
|
|
804
780
|
markActivity(): void {
|
|
@@ -820,7 +796,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
820
796
|
|
|
821
797
|
async release() {
|
|
822
798
|
this.statusPollTimer && clearInterval(this.statusPollTimer);
|
|
823
|
-
this.eventsRestartTimer && clearInterval(this.eventsRestartTimer);
|
|
824
799
|
return this.resetBaichuanClient();
|
|
825
800
|
}
|
|
826
801
|
|
|
@@ -831,10 +806,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
831
806
|
this.statusPollTimer = setInterval(() => {
|
|
832
807
|
this.periodic10sTick().catch(() => { });
|
|
833
808
|
}, 10_000);
|
|
834
|
-
|
|
835
|
-
this.eventsRestartTimer = setInterval(() => {
|
|
836
|
-
this.periodic60sRestartEvents().catch(() => { });
|
|
837
|
-
}, 60_000);
|
|
838
809
|
}
|
|
839
810
|
|
|
840
811
|
private async periodic10sTick(): Promise<void> {
|
|
@@ -846,46 +817,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
846
817
|
}
|
|
847
818
|
|
|
848
819
|
await this.refreshAuxDevicesStatus();
|
|
849
|
-
|
|
850
|
-
// Best-effort: ensure we're subscribed.
|
|
851
|
-
if (this.isEventDispatchEnabled() && !this.subscribedToEvents) {
|
|
852
|
-
if (this.hasBattery()) {
|
|
853
|
-
const api = this.getClient();
|
|
854
|
-
if (!api?.client?.loggedIn) return;
|
|
855
|
-
}
|
|
856
|
-
await this.ensureBaichuanEventSubscription();
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
private async periodic60sRestartEvents(): Promise<void> {
|
|
861
|
-
if (this.shouldAvoidWakingBatteryCamera()) return;
|
|
862
|
-
|
|
863
|
-
if (!this.isEventDispatchEnabled()) {
|
|
864
|
-
await this.disableBaichuanEventSubscription();
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// Wired cameras can reconnect; battery cameras only operate on an existing active client.
|
|
869
|
-
if (!this.hasBattery()) {
|
|
870
|
-
await this.ensureClient();
|
|
871
|
-
}
|
|
872
|
-
else {
|
|
873
|
-
const api = this.getClient();
|
|
874
|
-
if (!api?.client?.loggedIn) return;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const api = this.getClient();
|
|
878
|
-
if (!api) return;
|
|
879
|
-
|
|
880
|
-
try {
|
|
881
|
-
await api.unsubscribeEvents();
|
|
882
|
-
}
|
|
883
|
-
catch {
|
|
884
|
-
// ignore
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
this.subscribedToEvents = false;
|
|
888
|
-
await this.ensureBaichuanEventSubscription();
|
|
889
820
|
}
|
|
890
821
|
|
|
891
822
|
private async refreshAuxDevicesStatus(): Promise<void> {
|
|
@@ -919,48 +850,45 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
919
850
|
}
|
|
920
851
|
|
|
921
852
|
async getVideoTextOverlays(): Promise<Record<string, VideoTextOverlay>> {
|
|
922
|
-
const client = this.
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
// }
|
|
937
|
-
// }
|
|
853
|
+
const client = await this.ensureClient();
|
|
854
|
+
const channel = this.getRtspChannel();
|
|
855
|
+
|
|
856
|
+
const osd = await client.getOsd(channel);
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
osdChannel: {
|
|
860
|
+
text: osd?.osdChannel?.enable ? osd.osdChannel.name : undefined,
|
|
861
|
+
},
|
|
862
|
+
osdTime: {
|
|
863
|
+
text: !!osd?.osdTime?.enable,
|
|
864
|
+
readonly: true,
|
|
865
|
+
},
|
|
866
|
+
};
|
|
938
867
|
}
|
|
939
868
|
|
|
940
869
|
async setVideoTextOverlay(id: 'osdChannel' | 'osdTime', value: VideoTextOverlay): Promise<void> {
|
|
941
870
|
const client = await this.ensureClient();
|
|
942
|
-
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
// TODO: restore
|
|
871
|
+
const channel = this.getRtspChannel();
|
|
946
872
|
|
|
947
|
-
|
|
873
|
+
const osd = await client.getOsd(channel);
|
|
948
874
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
875
|
+
if (id === 'osdChannel') {
|
|
876
|
+
const nextName = typeof value?.text === 'string' ? value.text.trim() : '';
|
|
877
|
+
const enable = !!nextName || value?.text === true;
|
|
878
|
+
osd.osdChannel.enable = enable ? 1 : 0;
|
|
879
|
+
// Name must always be valid when enabled.
|
|
880
|
+
if (enable) {
|
|
881
|
+
osd.osdChannel.name = nextName || osd.osdChannel.name || this.name || 'Camera';
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
else if (id === 'osdTime') {
|
|
885
|
+
osd.osdTime.enable = value?.text ? 1 : 0;
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
throw new Error('unknown overlay: ' + id);
|
|
889
|
+
}
|
|
962
890
|
|
|
963
|
-
|
|
891
|
+
await client.setOsd(channel, osd);
|
|
964
892
|
}
|
|
965
893
|
|
|
966
894
|
updatePtzCaps() {
|
|
@@ -991,7 +919,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
991
919
|
const channel = this.getRtspChannel();
|
|
992
920
|
|
|
993
921
|
// Preset navigation.
|
|
994
|
-
const preset =
|
|
922
|
+
const preset = command.preset;
|
|
995
923
|
if (preset !== undefined && preset !== null) {
|
|
996
924
|
const presetId = Number(preset);
|
|
997
925
|
if (!Number.isFinite(presetId)) {
|
|
@@ -1105,6 +1033,11 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1105
1033
|
return Boolean(capabilities?.hasIntercom);
|
|
1106
1034
|
}
|
|
1107
1035
|
|
|
1036
|
+
isDoorbell() {
|
|
1037
|
+
const capabilities = this.getAbilities() as any;
|
|
1038
|
+
return Boolean(capabilities?.isDoorbell);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1108
1041
|
getPtzCapabilities() {
|
|
1109
1042
|
const capabilities = this.getAbilities();
|
|
1110
1043
|
const hasZoom = Boolean(capabilities?.hasZoom);
|
|
@@ -1129,35 +1062,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1129
1062
|
return Boolean(capabilities?.hasPir);
|
|
1130
1063
|
}
|
|
1131
1064
|
|
|
1132
|
-
async getDeviceInterfaces() {
|
|
1133
|
-
const interfaces = [
|
|
1134
|
-
ScryptedInterface.VideoCamera,
|
|
1135
|
-
ScryptedInterface.Settings,
|
|
1136
|
-
...this.plugin.getCameraInterfaces(),
|
|
1137
|
-
];
|
|
1138
|
-
|
|
1139
|
-
try {
|
|
1140
|
-
const { hasPtz } = this.getPtzCapabilities();
|
|
1141
|
-
|
|
1142
|
-
if (hasPtz) {
|
|
1143
|
-
interfaces.push(ScryptedInterface.PanTiltZoom);
|
|
1144
|
-
}
|
|
1145
|
-
interfaces.push(ScryptedInterface.ObjectDetector);
|
|
1146
|
-
if (this.hasSiren() || this.hasFloodlight() || this.hasPirEvents())
|
|
1147
|
-
interfaces.push(ScryptedInterface.DeviceProvider);
|
|
1148
|
-
if (this.hasBattery()) {
|
|
1149
|
-
interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
|
|
1150
|
-
}
|
|
1151
|
-
if (this.hasIntercom()) {
|
|
1152
|
-
interfaces.push(ScryptedInterface.Intercom);
|
|
1153
|
-
}
|
|
1154
|
-
} catch (e) {
|
|
1155
|
-
this.getLogger().error('Error getting device interfaces', e);
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
return interfaces;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
1065
|
async processBatteryData(data: BatteryInfo) {
|
|
1162
1066
|
const logger = this.getLogger();
|
|
1163
1067
|
const batteryLevel = data.batteryPercent;
|
|
@@ -1201,7 +1105,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1201
1105
|
|
|
1202
1106
|
if (!this.isEventDispatchEnabled()) return;
|
|
1203
1107
|
|
|
1204
|
-
if (this.
|
|
1108
|
+
if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
|
|
1205
1109
|
logger.debug(`Events received: ${JSON.stringify(events)}`);
|
|
1206
1110
|
}
|
|
1207
1111
|
|
|
@@ -1237,40 +1141,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1237
1141
|
}
|
|
1238
1142
|
}
|
|
1239
1143
|
|
|
1240
|
-
private normalizeDebugLogs(value: unknown): string[] {
|
|
1241
|
-
const allowed = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets', 'eventLogs']);
|
|
1242
|
-
|
|
1243
|
-
const items = Array.isArray(value) ? value : (typeof value === 'string' ? [value] : []);
|
|
1244
|
-
const out: string[] = [];
|
|
1245
|
-
for (const v of items) {
|
|
1246
|
-
if (typeof v !== 'string') continue;
|
|
1247
|
-
const s = v.trim();
|
|
1248
|
-
if (!allowed.has(s)) continue;
|
|
1249
|
-
out.push(s);
|
|
1250
|
-
}
|
|
1251
|
-
return Array.from(new Set(out));
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
private getBaichuanDebugOptions(): any | undefined {
|
|
1255
|
-
const sel = new Set(this.normalizeDebugLogs((this.storageSettings.values as any).debugLogs));
|
|
1256
|
-
if (!sel.size) return undefined;
|
|
1257
|
-
|
|
1258
|
-
// Keep this as `any` so we don't need to import DebugOptions types here.
|
|
1259
|
-
const debugOptions: any = {};
|
|
1260
|
-
// Only pass through Baichuan client debug flags.
|
|
1261
|
-
const clientKeys = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets']);
|
|
1262
|
-
for (const k of sel) {
|
|
1263
|
-
if (!clientKeys.has(k)) continue;
|
|
1264
|
-
debugOptions[k] = true;
|
|
1265
|
-
}
|
|
1266
|
-
return Object.keys(debugOptions).length ? debugOptions : undefined;
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
private isEventLogsEnabled(): boolean {
|
|
1270
|
-
const sel = new Set(this.normalizeDebugLogs((this.storageSettings.values as any).debugLogs));
|
|
1271
|
-
return sel.has('eventLogs');
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
1144
|
private getDispatchEventsSelection(): Set<'motion' | 'objects'> {
|
|
1275
1145
|
return new Set(this.storageSettings.values.dispatchEvents);
|
|
1276
1146
|
}
|
|
@@ -1287,26 +1157,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1287
1157
|
return this.getDispatchEventsSelection().has('objects');
|
|
1288
1158
|
}
|
|
1289
1159
|
|
|
1290
|
-
private scheduleApplyEventDispatchSettings(): void {
|
|
1291
|
-
// Debounce to avoid rapid apply loops while editing multi-select.
|
|
1292
|
-
this.dispatchEventsApplySeq++;
|
|
1293
|
-
const seq = this.dispatchEventsApplySeq;
|
|
1294
|
-
|
|
1295
|
-
if (this.dispatchEventsApplyTimer) {
|
|
1296
|
-
clearTimeout(this.dispatchEventsApplyTimer);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
this.dispatchEventsApplyTimer = setTimeout(() => {
|
|
1300
|
-
// Fire-and-forget; never block settings UI.
|
|
1301
|
-
this.applyEventDispatchSettings().catch((e) => {
|
|
1302
|
-
// Only log once per debounce window.
|
|
1303
|
-
if (seq === this.dispatchEventsApplySeq) {
|
|
1304
|
-
this.getLogger().warn('Failed to apply Dispatch Events setting', e);
|
|
1305
|
-
}
|
|
1306
|
-
});
|
|
1307
|
-
}, 300);
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
1160
|
async takeSnapshotInternal(timeout?: number) {
|
|
1311
1161
|
this.markActivity();
|
|
1312
1162
|
return this.withBaichuanRetry(async () => {
|
|
@@ -1357,7 +1207,9 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1357
1207
|
}
|
|
1358
1208
|
|
|
1359
1209
|
if (canTake) {
|
|
1360
|
-
|
|
1210
|
+
if (this.connectionTime && (Date.now() - this.connectionTime) > 5000) {
|
|
1211
|
+
return this.takeSnapshotInternal(options?.timeout);
|
|
1212
|
+
}
|
|
1361
1213
|
} else if (this.lastB64Snapshot) {
|
|
1362
1214
|
const mo = await b64ToMo(this.lastB64Snapshot);
|
|
1363
1215
|
|
|
@@ -1382,6 +1234,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1382
1234
|
const selected = vsos?.find(s => s.id === vso.id) || vsos?.[0];
|
|
1383
1235
|
if (!selected)
|
|
1384
1236
|
throw new Error('No stream options available');
|
|
1237
|
+
this.getLogger().log(`Creating video stream for option id=${selected.id} name=${selected.name}`);
|
|
1385
1238
|
|
|
1386
1239
|
const profile = parseStreamProfileFromId(selected.id) || 'main';
|
|
1387
1240
|
|
|
@@ -1393,17 +1246,15 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1393
1246
|
: selected?.video?.codec?.includes('264') ? 'H264'
|
|
1394
1247
|
: undefined;
|
|
1395
1248
|
|
|
1396
|
-
const { host, port, sdp, audio } = await this.streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType
|
|
1249
|
+
const { host, port, sdp, audio } = await this.streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
|
|
1397
1250
|
|
|
1398
1251
|
const { url: _ignoredUrl, ...mso }: any = selected;
|
|
1399
|
-
// This stream is delivered as RFC4571 (RTP over raw TCP), not RTSP.
|
|
1400
|
-
// Mark it accordingly to avoid RTSP-specific handling in downstream plugins.
|
|
1401
1252
|
mso.container = 'rtp';
|
|
1402
1253
|
if (audio) {
|
|
1403
1254
|
mso.audio ||= {};
|
|
1404
1255
|
mso.audio.codec = audio.codec;
|
|
1405
1256
|
mso.audio.sampleRate = audio.sampleRate;
|
|
1406
|
-
|
|
1257
|
+
mso.audio.channels = audio.channels;
|
|
1407
1258
|
}
|
|
1408
1259
|
|
|
1409
1260
|
const rfc = {
|