@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/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: {
@@ -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 || Array.from(oldSel).some((k) => !newSel.has(k));
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.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;
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
- const { ipAddress, username, password, uid } = this.storageSettings.values;
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 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);
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
- try {
656
+ try {
657
+ const interfaces = getDeviceInterfaces({
658
+ capabilities,
659
+ logger: this.console,
660
+ });
632
661
 
633
- const interfaces = await this.getDeviceInterfaces();
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
- const device: Device = {
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
- logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
673
+ await sdk.deviceManager.onDeviceDiscovered(device);
674
+ } catch (e) {
675
+ logger.error('Failed to update device interfaces', e);
676
+ }
645
677
 
646
- await sdk.deviceManager.onDeviceDiscovered(device);
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
- this.refreshingState = false;
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 api.subscribeEvents();
685
+ await this.refreshAuxDevicesStatus();
664
686
  }
665
- catch {
666
- // Some firmwares don't require explicit subscribe or may reject it.
687
+ catch (e) {
688
+ logger.error('Failed to refresh device status', e);
667
689
  }
668
690
 
669
- this.onSimpleEvent ||= (ev: any) => {
670
- try {
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
- this.processEvents({ motion, objects }).catch(() => { });
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
- catch {
704
- // 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;
705
730
  }
706
- };
707
731
 
708
- // Attach the handler to the current API instance, and detach from any previous instance.
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
- if (this.eventsApi !== api && this.onSimpleEvent) {
718
- api.simpleEvents.on('event', this.onSimpleEvent);
719
- this.eventsApi = api;
734
+ catch {
735
+ // ignore
720
736
  }
721
-
722
- this.subscribedToEvents = true;
723
737
  }
724
738
 
725
- private async disableBaichuanEventSubscription(): Promise<void> {
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
- if (api?.client?.loggedIn) {
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 applyEventDispatchSettings(): Promise<void> {
744
+ private async subscribeToEvents(): Promise<void> {
756
745
  const logger = this.getLogger();
757
746
  const selection = Array.from(this.getDispatchEventsSelection()).sort();
758
- const key = selection.join(',');
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
- // User-initiated settings change counts as activity.
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
- // Apply immediately even if we were already subscribed.
785
- // If nothing actually changed and we're already subscribed, avoid a noisy resubscribe.
786
- if (prevKey === key && this.subscribedToEvents) {
787
- // Track baseline so later changes are logged.
788
- 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;
789
765
  return;
790
766
  }
791
767
 
792
- if (!this.subscribedToEvents) {
793
- logger.log(`Event listener started (${selection.join(', ')})`);
794
- await this.ensureBaichuanEventSubscription();
795
- 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);
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.getClient();
924
- if (!client) {
925
- return;
926
- }
927
- // TODO: restore
928
- // const { cachedOsd } = this.storageSettings.values;
929
-
930
- // return {
931
- // osdChannel: {
932
- // text: cachedOsd.value.Osd.osdChannel.enable ? cachedOsd.value.Osd.osdChannel.name : undefined,
933
- // },
934
- // osdTime: {
935
- // text: !!cachedOsd.value.Osd.osdTime.enable,
936
- // readonly: true,
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
- if (!client) {
944
- return;
945
- }
946
- // TODO: restore
871
+ const channel = this.getRtspChannel();
947
872
 
948
- // const osd = await client.getOsd();
873
+ const osd = await client.getOsd(channel);
949
874
 
950
- // if (id === 'osdChannel') {
951
- // osd.osdChannel.enable = value.text ? 1 : 0;
952
- // // name must always be valid.
953
- // osd.osdChannel.name = typeof value.text === 'string' && value.text
954
- // ? value.text
955
- // : osd.osdChannel.name || 'Camera';
956
- // }
957
- // else if (id === 'osdTime') {
958
- // osd.osdTime.enable = value.text ? 1 : 0;
959
- // }
960
- // else {
961
- // throw new Error('unknown overlay: ' + id);
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
- // await client.setOsd(channel, osd);
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 = (command as any).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.isEventLogsEnabled()) {
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
- return this.takeSnapshotInternal(options?.timeout);
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 as any);
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
- (mso.audio as any).channels = audio.channels;
1257
+ mso.audio.channels = audio.channels;
1416
1258
  }
1417
1259
 
1418
1260
  const rfc = {