@apocaliss92/scrypted-reolink-native 0.0.3 → 0.0.5

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/src/camera.ts CHANGED
@@ -1,13 +1,13 @@
1
- import type { BatteryInfo, DeviceCapabilities, PtzCommand, ReolinkBaichuanApi, ReolinkSimpleEvent, StreamProfile } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
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 { connectBaichuanWithTcpUdpFallback, createBaichuanApi, maskUid } from './connect';
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.scheduleApplyEventDispatchSettings();
222
+ await this.subscribeToEvents();
233
223
  },
234
224
  },
235
225
  debugLogs: {
@@ -240,7 +230,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
240
230
  combobox: true,
241
231
  immediate: true,
242
232
  defaultValue: [],
243
- choices: ['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets', 'eventLogs'],
233
+ choices: ['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'traceEvents', 'debugH264', 'debugParamSets', 'eventLogs'],
244
234
  onPut: async (ov, value) => {
245
235
  // Only reconnect if Baichuan-client flags changed; toggling event logs should be immediate.
246
236
  const oldSel = new Set(ov);
@@ -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.subscribedToEvents = false;
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
- if (this.isEventDispatchEnabled()) {
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
- const { api } = await connectBaichuanWithTcpUdpFallback(
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 async createStreamClient(): Promise<ReolinkBaichuanApi> {
575
- // Ensure the main client is initialized first so we know if this device needs UDP.
576
- const primary = await this.ensureClient();
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 { ipAddress, username, password, uid } = this.storageSettings.values;
592
+ const debugOptions: DebugOptions = {};
593
+ // Only pass through Baichuan client debug flags.
594
+ const clientKeys = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'traceEvents', '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 api.getDeviceCapabilities(channel);
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 async ensureBaichuanEventSubscription(): Promise<void> {
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
- await api.subscribeEvents();
663
- }
664
- catch {
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
- catch {
703
- // ignore
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
- // Attach the handler to the current API instance, and detach from any previous instance.
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
- if (this.eventsApi !== api && this.onSimpleEvent) {
717
- api.simpleEvents.on('event', this.onSimpleEvent);
718
- this.eventsApi = api;
734
+ catch {
735
+ // ignore
719
736
  }
720
-
721
- this.subscribedToEvents = true;
722
737
  }
723
738
 
724
- private async disableBaichuanEventSubscription(): Promise<void> {
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
- if (api?.client?.loggedIn) {
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 applyEventDispatchSettings(): Promise<void> {
744
+ private async subscribeToEvents(): Promise<void> {
755
745
  const logger = this.getLogger();
756
746
  const selection = Array.from(this.getDispatchEventsSelection()).sort();
757
- const key = selection.join(',');
758
- const prevKey = this.lastAppliedDispatchEventsKey;
747
+ const enabled = selection.length > 0;
759
748
 
760
- if (prevKey !== undefined && prevKey !== key) {
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
- // Apply immediately even if we were already subscribed.
784
- // If nothing actually changed and we're already subscribed, avoid a noisy resubscribe.
785
- if (prevKey === key && this.subscribedToEvents) {
786
- // Track baseline so later changes are logged.
787
- this.lastAppliedDispatchEventsKey = key;
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
- if (!this.subscribedToEvents) {
792
- logger.log(`Event listener started (${selection.join(', ')})`);
793
- await this.ensureBaichuanEventSubscription();
794
- this.lastAppliedDispatchEventsKey = key;
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.getClient();
923
- if (!client) {
924
- return;
925
- }
926
- // TODO: restore
927
- // const { cachedOsd } = this.storageSettings.values;
928
-
929
- // return {
930
- // osdChannel: {
931
- // text: cachedOsd.value.Osd.osdChannel.enable ? cachedOsd.value.Osd.osdChannel.name : undefined,
932
- // },
933
- // osdTime: {
934
- // text: !!cachedOsd.value.Osd.osdTime.enable,
935
- // readonly: true,
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
- if (!client) {
943
- return;
944
- }
945
- // TODO: restore
871
+ const channel = this.getRtspChannel();
946
872
 
947
- // const osd = await client.getOsd();
873
+ const osd = await client.getOsd(channel);
948
874
 
949
- // if (id === 'osdChannel') {
950
- // osd.osdChannel.enable = value.text ? 1 : 0;
951
- // // name must always be valid.
952
- // osd.osdChannel.name = typeof value.text === 'string' && value.text
953
- // ? value.text
954
- // : osd.osdChannel.name || 'Camera';
955
- // }
956
- // else if (id === 'osdTime') {
957
- // osd.osdTime.enable = value.text ? 1 : 0;
958
- // }
959
- // else {
960
- // throw new Error('unknown overlay: ' + id);
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
- // await client.setOsd(channel, osd);
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 = (command as any).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.isEventLogsEnabled()) {
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
- return this.takeSnapshotInternal(options?.timeout);
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 as any);
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
- (mso.audio as any).channels = audio.channels;
1257
+ mso.audio.channels = audio.channels;
1407
1258
  }
1408
1259
 
1409
1260
  const rfc = {