@apocaliss92/scrypted-reolink-native 0.0.2 → 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 +211 -369
- 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
|
-
|
|
588
|
+
private getBaichuanDebugOptions(): any | undefined {
|
|
589
|
+
const sel = new Set<string>(this.storageSettings.values.debugLogs);
|
|
590
|
+
if (!sel.size) return undefined;
|
|
591
|
+
|
|
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
|
+
}
|
|
578
601
|
|
|
579
|
-
|
|
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,194 +644,137 @@ 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);
|
|
618
|
-
this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
|
|
619
|
-
}
|
|
620
|
-
catch (e) {
|
|
621
|
-
logger.error('Failed to refresh abilities', e);
|
|
622
|
-
}
|
|
623
654
|
|
|
624
|
-
// try {
|
|
625
|
-
// await this.refreshAuxDevicesStatus();
|
|
626
|
-
// }
|
|
627
|
-
// catch (e) {
|
|
628
|
-
// logger.error('Failed to refresh device status', e);
|
|
629
|
-
// }
|
|
630
655
|
|
|
631
|
-
|
|
656
|
+
try {
|
|
657
|
+
const interfaces = getDeviceInterfaces({
|
|
658
|
+
capabilities,
|
|
659
|
+
logger: this.console,
|
|
660
|
+
});
|
|
632
661
|
|
|
633
|
-
|
|
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
|
+
};
|
|
634
670
|
|
|
635
|
-
|
|
636
|
-
nativeId: this.nativeId,
|
|
637
|
-
providerNativeId: this.plugin.nativeId,
|
|
638
|
-
name: this.name,
|
|
639
|
-
interfaces,
|
|
640
|
-
type: this.type as ScryptedDeviceType,
|
|
641
|
-
info: this.info,
|
|
642
|
-
};
|
|
671
|
+
logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
|
|
643
672
|
|
|
644
|
-
|
|
673
|
+
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
logger.error('Failed to update device interfaces', e);
|
|
676
|
+
}
|
|
645
677
|
|
|
646
|
-
|
|
647
|
-
} catch (e) {
|
|
648
|
-
logger.error('Failed to update device interfaces', e);
|
|
678
|
+
this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
|
|
649
679
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
private async ensureBaichuanEventSubscription(): Promise<void> {
|
|
655
|
-
if (!this.isEventDispatchEnabled()) {
|
|
656
|
-
await this.disableBaichuanEventSubscription();
|
|
657
|
-
return;
|
|
680
|
+
catch (e) {
|
|
681
|
+
logger.error('Failed to refresh abilities', e);
|
|
658
682
|
}
|
|
659
|
-
if (this.subscribedToEvents) return;
|
|
660
|
-
const api = await this.ensureClient();
|
|
661
683
|
|
|
662
684
|
try {
|
|
663
|
-
await
|
|
685
|
+
await this.refreshAuxDevicesStatus();
|
|
664
686
|
}
|
|
665
|
-
catch {
|
|
666
|
-
|
|
687
|
+
catch (e) {
|
|
688
|
+
logger.error('Failed to refresh device status', e);
|
|
667
689
|
}
|
|
668
690
|
|
|
669
|
-
this.
|
|
670
|
-
|
|
671
|
-
if (!this.isEventDispatchEnabled()) return;
|
|
672
|
-
if (this.isEventLogsEnabled()) {
|
|
673
|
-
this.getLogger().debug(`Baichuan event: ${JSON.stringify(ev)}`);
|
|
674
|
-
}
|
|
675
|
-
const channel = this.getRtspChannel();
|
|
676
|
-
if (ev?.channel !== undefined && ev.channel !== channel) return;
|
|
677
|
-
|
|
678
|
-
const objects: string[] = [];
|
|
679
|
-
let motion = false;
|
|
680
|
-
|
|
681
|
-
switch (ev?.type) {
|
|
682
|
-
case 'motion':
|
|
683
|
-
motion = this.shouldDispatchMotion();
|
|
684
|
-
break;
|
|
685
|
-
case 'doorbell':
|
|
686
|
-
// Placeholder: treat doorbell as motion.
|
|
687
|
-
motion = this.shouldDispatchMotion();
|
|
688
|
-
break;
|
|
689
|
-
case 'people':
|
|
690
|
-
case 'vehicle':
|
|
691
|
-
case 'animal':
|
|
692
|
-
case 'face':
|
|
693
|
-
case 'package':
|
|
694
|
-
case 'other':
|
|
695
|
-
if (this.shouldDispatchObjects()) objects.push(ev.type);
|
|
696
|
-
break;
|
|
697
|
-
default:
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
691
|
+
this.refreshingState = false;
|
|
692
|
+
}
|
|
700
693
|
|
|
701
|
-
|
|
694
|
+
private onSimpleEvent = (ev: any) => {
|
|
695
|
+
try {
|
|
696
|
+
if (!this.isEventDispatchEnabled()) return;
|
|
697
|
+
if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
|
|
698
|
+
this.getLogger().debug(`Baichuan event: ${JSON.stringify(ev)}`);
|
|
702
699
|
}
|
|
703
|
-
|
|
704
|
-
|
|
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;
|
|
705
730
|
}
|
|
706
|
-
};
|
|
707
731
|
|
|
708
|
-
|
|
709
|
-
if (this.eventsApi && this.eventsApi !== api && this.onSimpleEvent) {
|
|
710
|
-
try {
|
|
711
|
-
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
712
|
-
}
|
|
713
|
-
catch {
|
|
714
|
-
// ignore
|
|
715
|
-
}
|
|
732
|
+
this.processEvents({ motion, objects }).catch(() => { });
|
|
716
733
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
this.eventsApi = api;
|
|
734
|
+
catch {
|
|
735
|
+
// ignore
|
|
720
736
|
}
|
|
721
|
-
|
|
722
|
-
this.subscribedToEvents = true;
|
|
723
737
|
}
|
|
724
738
|
|
|
725
|
-
private
|
|
726
|
-
// Do not wake up battery cameras / do not force login: best-effort cleanup only.
|
|
739
|
+
private unsubscribedToEvents() {
|
|
727
740
|
const api = this.getClient();
|
|
728
|
-
|
|
729
|
-
try {
|
|
730
|
-
await api.unsubscribeEvents();
|
|
731
|
-
}
|
|
732
|
-
catch {
|
|
733
|
-
// ignore
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
if (this.eventsApi && this.onSimpleEvent) {
|
|
738
|
-
try {
|
|
739
|
-
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
740
|
-
}
|
|
741
|
-
catch {
|
|
742
|
-
// ignore
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
this.subscribedToEvents = false;
|
|
747
|
-
this.eventsApi = undefined;
|
|
748
|
-
|
|
749
|
-
if (this.motionTimeout) {
|
|
750
|
-
clearTimeout(this.motionTimeout);
|
|
751
|
-
}
|
|
752
|
-
this.motionDetected = false;
|
|
741
|
+
api.offSimpleEvent(this.onSimpleEvent);
|
|
753
742
|
}
|
|
754
743
|
|
|
755
|
-
private async
|
|
744
|
+
private async subscribeToEvents(): Promise<void> {
|
|
756
745
|
const logger = this.getLogger();
|
|
757
746
|
const selection = Array.from(this.getDispatchEventsSelection()).sort();
|
|
758
|
-
const
|
|
759
|
-
const prevKey = this.lastAppliedDispatchEventsKey;
|
|
760
|
-
|
|
761
|
-
if (prevKey !== undefined && prevKey !== key) {
|
|
762
|
-
logger.log(`Dispatch Events changed: ${selection.length ? selection.join(', ') : '(disabled)'}`);
|
|
763
|
-
}
|
|
747
|
+
const enabled = selection.length > 0;
|
|
764
748
|
|
|
765
|
-
//
|
|
749
|
+
// Settings change / init counts as activity.
|
|
766
750
|
this.markActivity();
|
|
767
751
|
|
|
768
|
-
// Empty selection disables everything.
|
|
769
|
-
if (!this.isEventDispatchEnabled()) {
|
|
770
|
-
if (this.subscribedToEvents) {
|
|
771
|
-
logger.log('Event listener stopped (Dispatch Events disabled)');
|
|
772
|
-
}
|
|
773
|
-
await this.disableBaichuanEventSubscription();
|
|
774
|
-
this.lastAppliedDispatchEventsKey = key;
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// If motion is not selected, ensure state is cleared.
|
|
779
752
|
if (!this.shouldDispatchMotion()) {
|
|
780
753
|
if (this.motionTimeout) clearTimeout(this.motionTimeout);
|
|
781
754
|
this.motionDetected = false;
|
|
782
755
|
}
|
|
783
756
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
|
|
788
|
-
|
|
757
|
+
this.unsubscribedToEvents();
|
|
758
|
+
|
|
759
|
+
if (!enabled) {
|
|
760
|
+
if (this.doorbellBinaryTimeout) {
|
|
761
|
+
clearTimeout(this.doorbellBinaryTimeout);
|
|
762
|
+
this.doorbellBinaryTimeout = undefined;
|
|
763
|
+
}
|
|
764
|
+
this.binaryState = false;
|
|
789
765
|
return;
|
|
790
766
|
}
|
|
791
767
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
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);
|
|
796
776
|
return;
|
|
797
777
|
}
|
|
798
|
-
|
|
799
|
-
logger.log(`Event listener restarting (${selection.join(', ')})`);
|
|
800
|
-
await this.disableBaichuanEventSubscription();
|
|
801
|
-
await this.ensureBaichuanEventSubscription();
|
|
802
|
-
this.lastAppliedDispatchEventsKey = key;
|
|
803
778
|
}
|
|
804
779
|
|
|
805
780
|
markActivity(): void {
|
|
@@ -821,7 +796,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
821
796
|
|
|
822
797
|
async release() {
|
|
823
798
|
this.statusPollTimer && clearInterval(this.statusPollTimer);
|
|
824
|
-
this.eventsRestartTimer && clearInterval(this.eventsRestartTimer);
|
|
825
799
|
return this.resetBaichuanClient();
|
|
826
800
|
}
|
|
827
801
|
|
|
@@ -832,10 +806,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
832
806
|
this.statusPollTimer = setInterval(() => {
|
|
833
807
|
this.periodic10sTick().catch(() => { });
|
|
834
808
|
}, 10_000);
|
|
835
|
-
|
|
836
|
-
this.eventsRestartTimer = setInterval(() => {
|
|
837
|
-
this.periodic60sRestartEvents().catch(() => { });
|
|
838
|
-
}, 60_000);
|
|
839
809
|
}
|
|
840
810
|
|
|
841
811
|
private async periodic10sTick(): Promise<void> {
|
|
@@ -847,46 +817,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
847
817
|
}
|
|
848
818
|
|
|
849
819
|
await this.refreshAuxDevicesStatus();
|
|
850
|
-
|
|
851
|
-
// Best-effort: ensure we're subscribed.
|
|
852
|
-
if (this.isEventDispatchEnabled() && !this.subscribedToEvents) {
|
|
853
|
-
if (this.hasBattery()) {
|
|
854
|
-
const api = this.getClient();
|
|
855
|
-
if (!api?.client?.loggedIn) return;
|
|
856
|
-
}
|
|
857
|
-
await this.ensureBaichuanEventSubscription();
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
private async periodic60sRestartEvents(): Promise<void> {
|
|
862
|
-
if (this.shouldAvoidWakingBatteryCamera()) return;
|
|
863
|
-
|
|
864
|
-
if (!this.isEventDispatchEnabled()) {
|
|
865
|
-
await this.disableBaichuanEventSubscription();
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Wired cameras can reconnect; battery cameras only operate on an existing active client.
|
|
870
|
-
if (!this.hasBattery()) {
|
|
871
|
-
await this.ensureClient();
|
|
872
|
-
}
|
|
873
|
-
else {
|
|
874
|
-
const api = this.getClient();
|
|
875
|
-
if (!api?.client?.loggedIn) return;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
const api = this.getClient();
|
|
879
|
-
if (!api) return;
|
|
880
|
-
|
|
881
|
-
try {
|
|
882
|
-
await api.unsubscribeEvents();
|
|
883
|
-
}
|
|
884
|
-
catch {
|
|
885
|
-
// ignore
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
this.subscribedToEvents = false;
|
|
889
|
-
await this.ensureBaichuanEventSubscription();
|
|
890
820
|
}
|
|
891
821
|
|
|
892
822
|
private async refreshAuxDevicesStatus(): Promise<void> {
|
|
@@ -920,48 +850,45 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
920
850
|
}
|
|
921
851
|
|
|
922
852
|
async getVideoTextOverlays(): Promise<Record<string, VideoTextOverlay>> {
|
|
923
|
-
const client = this.
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
// }
|
|
938
|
-
// }
|
|
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
|
+
};
|
|
939
867
|
}
|
|
940
868
|
|
|
941
869
|
async setVideoTextOverlay(id: 'osdChannel' | 'osdTime', value: VideoTextOverlay): Promise<void> {
|
|
942
870
|
const client = await this.ensureClient();
|
|
943
|
-
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
// TODO: restore
|
|
871
|
+
const channel = this.getRtspChannel();
|
|
947
872
|
|
|
948
|
-
|
|
873
|
+
const osd = await client.getOsd(channel);
|
|
949
874
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
+
}
|
|
963
890
|
|
|
964
|
-
|
|
891
|
+
await client.setOsd(channel, osd);
|
|
965
892
|
}
|
|
966
893
|
|
|
967
894
|
updatePtzCaps() {
|
|
@@ -992,7 +919,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
992
919
|
const channel = this.getRtspChannel();
|
|
993
920
|
|
|
994
921
|
// Preset navigation.
|
|
995
|
-
const preset =
|
|
922
|
+
const preset = command.preset;
|
|
996
923
|
if (preset !== undefined && preset !== null) {
|
|
997
924
|
const presetId = Number(preset);
|
|
998
925
|
if (!Number.isFinite(presetId)) {
|
|
@@ -1101,6 +1028,16 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1101
1028
|
return Boolean(capabilities?.hasBattery);
|
|
1102
1029
|
}
|
|
1103
1030
|
|
|
1031
|
+
hasIntercom() {
|
|
1032
|
+
const capabilities = this.getAbilities();
|
|
1033
|
+
return Boolean(capabilities?.hasIntercom);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
isDoorbell() {
|
|
1037
|
+
const capabilities = this.getAbilities() as any;
|
|
1038
|
+
return Boolean(capabilities?.isDoorbell);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1104
1041
|
getPtzCapabilities() {
|
|
1105
1042
|
const capabilities = this.getAbilities();
|
|
1106
1043
|
const hasZoom = Boolean(capabilities?.hasZoom);
|
|
@@ -1125,48 +1062,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1125
1062
|
return Boolean(capabilities?.hasPir);
|
|
1126
1063
|
}
|
|
1127
1064
|
|
|
1128
|
-
async getDeviceInterfaces() {
|
|
1129
|
-
const interfaces = [
|
|
1130
|
-
ScryptedInterface.VideoCamera,
|
|
1131
|
-
ScryptedInterface.Settings,
|
|
1132
|
-
...this.plugin.getCameraInterfaces(),
|
|
1133
|
-
];
|
|
1134
|
-
|
|
1135
|
-
try {
|
|
1136
|
-
// Expose Intercom if the camera supports Baichuan talkback.
|
|
1137
|
-
try {
|
|
1138
|
-
const api = this.getClient();
|
|
1139
|
-
if (api) {
|
|
1140
|
-
const ability = await api.getTalkAbility(this.getRtspChannel());
|
|
1141
|
-
if (Array.isArray((ability as any)?.audioConfigList) && (ability as any).audioConfigList.length > 0) {
|
|
1142
|
-
interfaces.push(ScryptedInterface.Intercom);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
catch {
|
|
1147
|
-
// ignore: camera likely doesn't support talkback
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
const { hasPtz } = this.getPtzCapabilities();
|
|
1151
|
-
|
|
1152
|
-
if (hasPtz) {
|
|
1153
|
-
interfaces.push(ScryptedInterface.PanTiltZoom);
|
|
1154
|
-
}
|
|
1155
|
-
if ((await this.getObjectTypes()).classes.length > 0) {
|
|
1156
|
-
interfaces.push(ScryptedInterface.ObjectDetector);
|
|
1157
|
-
}
|
|
1158
|
-
if (this.hasSiren() || this.hasFloodlight() || this.hasPirEvents())
|
|
1159
|
-
interfaces.push(ScryptedInterface.DeviceProvider);
|
|
1160
|
-
if (this.hasBattery()) {
|
|
1161
|
-
interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
|
|
1162
|
-
}
|
|
1163
|
-
} catch (e) {
|
|
1164
|
-
this.getLogger().error('Error getting device interfaces', e);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
return interfaces;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
1065
|
async processBatteryData(data: BatteryInfo) {
|
|
1171
1066
|
const logger = this.getLogger();
|
|
1172
1067
|
const batteryLevel = data.batteryPercent;
|
|
@@ -1210,7 +1105,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1210
1105
|
|
|
1211
1106
|
if (!this.isEventDispatchEnabled()) return;
|
|
1212
1107
|
|
|
1213
|
-
if (this.
|
|
1108
|
+
if (this.storageSettings.values.dispatchEvents.includes('eventLogs')) {
|
|
1214
1109
|
logger.debug(`Events received: ${JSON.stringify(events)}`);
|
|
1215
1110
|
}
|
|
1216
1111
|
|
|
@@ -1246,40 +1141,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1246
1141
|
}
|
|
1247
1142
|
}
|
|
1248
1143
|
|
|
1249
|
-
private normalizeDebugLogs(value: unknown): string[] {
|
|
1250
|
-
const allowed = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets', 'eventLogs']);
|
|
1251
|
-
|
|
1252
|
-
const items = Array.isArray(value) ? value : (typeof value === 'string' ? [value] : []);
|
|
1253
|
-
const out: string[] = [];
|
|
1254
|
-
for (const v of items) {
|
|
1255
|
-
if (typeof v !== 'string') continue;
|
|
1256
|
-
const s = v.trim();
|
|
1257
|
-
if (!allowed.has(s)) continue;
|
|
1258
|
-
out.push(s);
|
|
1259
|
-
}
|
|
1260
|
-
return Array.from(new Set(out));
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
private getBaichuanDebugOptions(): any | undefined {
|
|
1264
|
-
const sel = new Set(this.normalizeDebugLogs((this.storageSettings.values as any).debugLogs));
|
|
1265
|
-
if (!sel.size) return undefined;
|
|
1266
|
-
|
|
1267
|
-
// Keep this as `any` so we don't need to import DebugOptions types here.
|
|
1268
|
-
const debugOptions: any = {};
|
|
1269
|
-
// Only pass through Baichuan client debug flags.
|
|
1270
|
-
const clientKeys = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets']);
|
|
1271
|
-
for (const k of sel) {
|
|
1272
|
-
if (!clientKeys.has(k)) continue;
|
|
1273
|
-
debugOptions[k] = true;
|
|
1274
|
-
}
|
|
1275
|
-
return Object.keys(debugOptions).length ? debugOptions : undefined;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
private isEventLogsEnabled(): boolean {
|
|
1279
|
-
const sel = new Set(this.normalizeDebugLogs((this.storageSettings.values as any).debugLogs));
|
|
1280
|
-
return sel.has('eventLogs');
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
1144
|
private getDispatchEventsSelection(): Set<'motion' | 'objects'> {
|
|
1284
1145
|
return new Set(this.storageSettings.values.dispatchEvents);
|
|
1285
1146
|
}
|
|
@@ -1296,26 +1157,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1296
1157
|
return this.getDispatchEventsSelection().has('objects');
|
|
1297
1158
|
}
|
|
1298
1159
|
|
|
1299
|
-
private scheduleApplyEventDispatchSettings(): void {
|
|
1300
|
-
// Debounce to avoid rapid apply loops while editing multi-select.
|
|
1301
|
-
this.dispatchEventsApplySeq++;
|
|
1302
|
-
const seq = this.dispatchEventsApplySeq;
|
|
1303
|
-
|
|
1304
|
-
if (this.dispatchEventsApplyTimer) {
|
|
1305
|
-
clearTimeout(this.dispatchEventsApplyTimer);
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
this.dispatchEventsApplyTimer = setTimeout(() => {
|
|
1309
|
-
// Fire-and-forget; never block settings UI.
|
|
1310
|
-
this.applyEventDispatchSettings().catch((e) => {
|
|
1311
|
-
// Only log once per debounce window.
|
|
1312
|
-
if (seq === this.dispatchEventsApplySeq) {
|
|
1313
|
-
this.getLogger().warn('Failed to apply Dispatch Events setting', e);
|
|
1314
|
-
}
|
|
1315
|
-
});
|
|
1316
|
-
}, 300);
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
1160
|
async takeSnapshotInternal(timeout?: number) {
|
|
1320
1161
|
this.markActivity();
|
|
1321
1162
|
return this.withBaichuanRetry(async () => {
|
|
@@ -1366,7 +1207,9 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1366
1207
|
}
|
|
1367
1208
|
|
|
1368
1209
|
if (canTake) {
|
|
1369
|
-
|
|
1210
|
+
if (this.connectionTime && (Date.now() - this.connectionTime) > 5000) {
|
|
1211
|
+
return this.takeSnapshotInternal(options?.timeout);
|
|
1212
|
+
}
|
|
1370
1213
|
} else if (this.lastB64Snapshot) {
|
|
1371
1214
|
const mo = await b64ToMo(this.lastB64Snapshot);
|
|
1372
1215
|
|
|
@@ -1391,6 +1234,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1391
1234
|
const selected = vsos?.find(s => s.id === vso.id) || vsos?.[0];
|
|
1392
1235
|
if (!selected)
|
|
1393
1236
|
throw new Error('No stream options available');
|
|
1237
|
+
this.getLogger().log(`Creating video stream for option id=${selected.id} name=${selected.name}`);
|
|
1394
1238
|
|
|
1395
1239
|
const profile = parseStreamProfileFromId(selected.id) || 'main';
|
|
1396
1240
|
|
|
@@ -1402,17 +1246,15 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1402
1246
|
: selected?.video?.codec?.includes('264') ? 'H264'
|
|
1403
1247
|
: undefined;
|
|
1404
1248
|
|
|
1405
|
-
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);
|
|
1406
1250
|
|
|
1407
1251
|
const { url: _ignoredUrl, ...mso }: any = selected;
|
|
1408
|
-
// This stream is delivered as RFC4571 (RTP over raw TCP), not RTSP.
|
|
1409
|
-
// Mark it accordingly to avoid RTSP-specific handling in downstream plugins.
|
|
1410
1252
|
mso.container = 'rtp';
|
|
1411
1253
|
if (audio) {
|
|
1412
1254
|
mso.audio ||= {};
|
|
1413
1255
|
mso.audio.codec = audio.codec;
|
|
1414
1256
|
mso.audio.sampleRate = audio.sampleRate;
|
|
1415
|
-
|
|
1257
|
+
mso.audio.channels = audio.channels;
|
|
1416
1258
|
}
|
|
1417
1259
|
|
|
1418
1260
|
const rfc = {
|