@apocaliss92/scrypted-reolink-native 0.5.45 → 0.5.47
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 +2 -2
- package/src/baichuan-base.ts +33 -9
- package/src/camera.ts +174 -38
- package/src/intercom.ts +76 -13
- package/src/multiFocal.ts +1 -1
- package/src/nvr.ts +1 -1
- package/src/utils.ts +1 -1
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apocaliss92/scrypted-reolink-native",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.47",
|
|
4
4
|
"description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
|
|
5
5
|
"author": "@apocaliss92",
|
|
6
6
|
"license": "Apache",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@apocaliss92/nodelink-js": "^0.6.
|
|
47
|
+
"@apocaliss92/nodelink-js": "^0.6.2",
|
|
48
48
|
"@scrypted/common": "file:../../scrypted/common",
|
|
49
49
|
"@scrypted/rtsp": "file:../../scrypted/plugins/rtsp",
|
|
50
50
|
"@scrypted/sdk": "^0.3.118"
|
package/src/baichuan-base.ts
CHANGED
|
@@ -192,6 +192,10 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
192
192
|
private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
|
|
193
193
|
private eventSubscriptionActive: boolean = false;
|
|
194
194
|
private lastEventTime: number = 0;
|
|
195
|
+
// Timestamp of the last triggered (silence-based) event restart. Used to
|
|
196
|
+
// rate-limit full unsub/resub cycles so a persistently-quiet camera can't be
|
|
197
|
+
// restarted on every check window.
|
|
198
|
+
private lastEventRestartTime: number = 0;
|
|
195
199
|
private currentWrappedEventHandler?: (ev: ReolinkSimpleEvent) => void;
|
|
196
200
|
private subscribeToEventsPromise?: Promise<void>;
|
|
197
201
|
private pingInterval?: NodeJS.Timeout;
|
|
@@ -940,20 +944,40 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
940
944
|
const timeSinceLastEvent = now - this.lastEventTime;
|
|
941
945
|
const tenMinutesMs = 10 * 60 * 1000;
|
|
942
946
|
|
|
947
|
+
// Rate-limit triggered restarts: never restart more than once per
|
|
948
|
+
// 10 minutes. A persistently-quiet camera otherwise gets a full
|
|
949
|
+
// unsub/resub cycle on every qualifying check window.
|
|
950
|
+
const canRestart =
|
|
951
|
+
now - this.lastEventRestartTime >= tenMinutesMs;
|
|
952
|
+
|
|
943
953
|
if (this.lastEventTime > 0 && timeSinceLastEvent > tenMinutesMs) {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
} else if (this.lastEventTime === 0) {
|
|
950
|
-
const timeSinceSubscription = now - (this.connectionTime || now);
|
|
951
|
-
if (timeSinceSubscription > tenMinutesMs) {
|
|
954
|
+
if (!canRestart) {
|
|
955
|
+
logger.debug(
|
|
956
|
+
"Event check: silence detected but skipping restart (backoff: last restart < 10min ago)",
|
|
957
|
+
);
|
|
958
|
+
} else {
|
|
952
959
|
logger.log(
|
|
953
|
-
`No events received
|
|
960
|
+
`No events received in the last ${Math.round(timeSinceLastEvent / 60_000)} minutes, performing full plugin-level event restart`,
|
|
954
961
|
);
|
|
955
962
|
await this.unsubscribeFromEvents(true);
|
|
956
963
|
await this.subscribeToEvents(true);
|
|
964
|
+
this.lastEventRestartTime = Date.now();
|
|
965
|
+
}
|
|
966
|
+
} else if (this.lastEventTime === 0) {
|
|
967
|
+
const timeSinceSubscription = now - (this.connectionTime || now);
|
|
968
|
+
if (timeSinceSubscription > tenMinutesMs) {
|
|
969
|
+
if (!canRestart) {
|
|
970
|
+
logger.debug(
|
|
971
|
+
"Event check: no events since subscription but skipping restart (backoff: last restart < 10min ago)",
|
|
972
|
+
);
|
|
973
|
+
} else {
|
|
974
|
+
logger.log(
|
|
975
|
+
`No events received since subscription (${Math.round(timeSinceSubscription / 60_000)} minutes ago), performing full plugin-level event restart`,
|
|
976
|
+
);
|
|
977
|
+
await this.unsubscribeFromEvents(true);
|
|
978
|
+
await this.subscribeToEvents(true);
|
|
979
|
+
this.lastEventRestartTime = Date.now();
|
|
980
|
+
}
|
|
957
981
|
}
|
|
958
982
|
}
|
|
959
983
|
} catch (e) {
|
package/src/camera.ts
CHANGED
|
@@ -495,6 +495,18 @@ export class ReolinkCamera
|
|
|
495
495
|
defaultValue: 60,
|
|
496
496
|
hide: true,
|
|
497
497
|
},
|
|
498
|
+
statusPollIntervalSeconds: {
|
|
499
|
+
title: "Accessory Status Poll Interval (seconds)",
|
|
500
|
+
subgroup: "Advanced",
|
|
501
|
+
description:
|
|
502
|
+
"How often to poll accessory device state (siren, floodlight, PIR, autotracking, chime) for wired cameras. 0 disables polling (push events still update state). Default: 60.",
|
|
503
|
+
type: "number",
|
|
504
|
+
defaultValue: 60,
|
|
505
|
+
hide: true,
|
|
506
|
+
onPut: async () => {
|
|
507
|
+
this.restartStatusPoll();
|
|
508
|
+
},
|
|
509
|
+
},
|
|
498
510
|
diagnosticsOutputPath: {
|
|
499
511
|
title: "Diagnostics Output Path",
|
|
500
512
|
subgroup: "Diagnostics",
|
|
@@ -593,6 +605,16 @@ export class ReolinkCamera
|
|
|
593
605
|
this.updateVideoClipsAutoLoad();
|
|
594
606
|
},
|
|
595
607
|
},
|
|
608
|
+
videoclipsHighWaterMark: {
|
|
609
|
+
// Internal: epoch ms of the most recent clip fully processed by the
|
|
610
|
+
// auto-load run. Subsequent runs start from this mark (minus a small
|
|
611
|
+
// overlap) instead of re-scanning the whole preload window every time.
|
|
612
|
+
// 0 means "no successful run yet" (full backfill).
|
|
613
|
+
type: "number",
|
|
614
|
+
defaultValue: 0,
|
|
615
|
+
hide: true,
|
|
616
|
+
readonly: true,
|
|
617
|
+
},
|
|
596
618
|
clearVideoclipsCache: {
|
|
597
619
|
title: "Clear All Video Clips Cache",
|
|
598
620
|
group: "Videoclips",
|
|
@@ -1528,6 +1550,10 @@ export class ReolinkCamera
|
|
|
1528
1550
|
// Recreate the cache directory for future use
|
|
1529
1551
|
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
1530
1552
|
|
|
1553
|
+
// Reset the auto-load high-water mark so the next run does a full
|
|
1554
|
+
// backfill over the configured preload window (the cache is now empty).
|
|
1555
|
+
this.storageSettings.values.videoclipsHighWaterMark = 0;
|
|
1556
|
+
|
|
1531
1557
|
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2);
|
|
1532
1558
|
logger.log(
|
|
1533
1559
|
`[VideoClips] Cache cleared: ${fileCount} files, ${sizeMB} MB freed. Cache directory recreated.`,
|
|
@@ -1568,8 +1594,9 @@ export class ReolinkCamera
|
|
|
1568
1594
|
`Starting video clips auto-load: checking every ${videoclipsRegularChecks} minutes`,
|
|
1569
1595
|
);
|
|
1570
1596
|
|
|
1571
|
-
//
|
|
1572
|
-
|
|
1597
|
+
// Stagger the initial run so many cameras don't hit their recordings
|
|
1598
|
+
// listing at the same instant on plugin startup.
|
|
1599
|
+
setTimeout(() => this.loadTodayVideoClipsAndThumbnails(), Math.random() * 60_000);
|
|
1573
1600
|
|
|
1574
1601
|
// Then run at regular intervals
|
|
1575
1602
|
this.videoClipsAutoLoadInterval = setInterval(() => {
|
|
@@ -1595,9 +1622,6 @@ export class ReolinkCamera
|
|
|
1595
1622
|
try {
|
|
1596
1623
|
const daysToPreload =
|
|
1597
1624
|
this.storageSettings.values.videoclipsDaysToPreload ?? 1;
|
|
1598
|
-
logger.log(
|
|
1599
|
-
`Auto-loading video clips and thumbnails for the last ${daysToPreload} day(s)...`,
|
|
1600
|
-
);
|
|
1601
1625
|
|
|
1602
1626
|
// Get date range (start of N days ago to now)
|
|
1603
1627
|
const now = new Date();
|
|
@@ -1606,21 +1630,42 @@ export class ReolinkCamera
|
|
|
1606
1630
|
startDate.setUTCHours(0, 0, 0, 0);
|
|
1607
1631
|
startDate.setUTCMinutes(0, 0, 0);
|
|
1608
1632
|
|
|
1609
|
-
//
|
|
1633
|
+
// Incremental load: on the first run (mark == 0) keep the full backfill
|
|
1634
|
+
// over the preload window; on subsequent runs start from the high-water
|
|
1635
|
+
// mark (minus a 5min overlap so we never miss a clip that straddled the
|
|
1636
|
+
// previous run boundary), but never earlier than the preload window.
|
|
1637
|
+
const highWaterMark =
|
|
1638
|
+
this.storageSettings.values.videoclipsHighWaterMark ?? 0;
|
|
1639
|
+
const OVERLAP_MS = 5 * 60 * 1000;
|
|
1640
|
+
const windowStartMs = startDate.getTime();
|
|
1641
|
+
const effectiveStartMs =
|
|
1642
|
+
highWaterMark > 0
|
|
1643
|
+
? Math.max(windowStartMs, highWaterMark - OVERLAP_MS)
|
|
1644
|
+
: windowStartMs;
|
|
1645
|
+
|
|
1646
|
+
logger.log(
|
|
1647
|
+
`Auto-loading video clips and thumbnails (${daysToPreload} day window, ` +
|
|
1648
|
+
`${highWaterMark > 0 ? "incremental from high-water mark" : "full backfill"})...`,
|
|
1649
|
+
);
|
|
1650
|
+
|
|
1651
|
+
// Fetch video clips for the (possibly narrowed) range
|
|
1610
1652
|
const clips = await this.getVideoClips({
|
|
1611
|
-
startTime:
|
|
1653
|
+
startTime: effectiveStartMs,
|
|
1612
1654
|
endTime: now.getTime(),
|
|
1613
1655
|
});
|
|
1614
1656
|
|
|
1615
|
-
logger.log(
|
|
1616
|
-
`Found ${clips.length} video clips for the last ${daysToPreload} day(s)`,
|
|
1617
|
-
);
|
|
1657
|
+
logger.log(`Found ${clips.length} video clips to process`);
|
|
1618
1658
|
|
|
1619
1659
|
const downloadVideoclipsLocally =
|
|
1620
1660
|
this.storageSettings.values.downloadVideoclipsLocally ?? false;
|
|
1621
1661
|
|
|
1622
1662
|
// Track processed clips to avoid duplicate calls to the camera
|
|
1623
1663
|
const processedClips = new Set<string>();
|
|
1664
|
+
// Most-recent clip start time fully processed this run; advances the mark.
|
|
1665
|
+
let maxProcessedStart = highWaterMark;
|
|
1666
|
+
// Only advance the high-water mark if the whole loop completes cleanly.
|
|
1667
|
+
let runHadError = false;
|
|
1668
|
+
const MIN_THUMB_CACHE_BYTES = 512;
|
|
1624
1669
|
|
|
1625
1670
|
// Download videos first (if enabled), then thumbnails for each clip
|
|
1626
1671
|
for (const clip of clips) {
|
|
@@ -1631,10 +1676,23 @@ export class ReolinkCamera
|
|
|
1631
1676
|
}
|
|
1632
1677
|
processedClips.add(clip.id);
|
|
1633
1678
|
|
|
1679
|
+
// Cheap pre-check: if the thumbnail is already cached and non-trivial,
|
|
1680
|
+
// skip the thumbnail generation entirely. The video file cache is
|
|
1681
|
+
// request/HLS-driven (not written by getVideoClip here), so it is NOT
|
|
1682
|
+
// used as a skip signal.
|
|
1683
|
+
let thumbnailCached = false;
|
|
1684
|
+
try {
|
|
1685
|
+
const thumbPath = this.getThumbnailCachePath(clip.id);
|
|
1686
|
+
const stats = await fs.promises.stat(thumbPath);
|
|
1687
|
+
thumbnailCached = stats.size >= MIN_THUMB_CACHE_BYTES;
|
|
1688
|
+
} catch {
|
|
1689
|
+
// Not cached (or unreadable) — fall through to generate it.
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1634
1692
|
try {
|
|
1635
|
-
// If downloadVideoclipsLocally is enabled,
|
|
1636
|
-
// This
|
|
1637
|
-
if (downloadVideoclipsLocally) {
|
|
1693
|
+
// If downloadVideoclipsLocally is enabled, build the video webhook URL.
|
|
1694
|
+
// This is cheap (no camera round-trip); only skip the thumbnail below.
|
|
1695
|
+
if (downloadVideoclipsLocally && !thumbnailCached) {
|
|
1638
1696
|
try {
|
|
1639
1697
|
// Call getVideoClip to trigger download and caching
|
|
1640
1698
|
await this.getVideoClip(clip.id);
|
|
@@ -1647,18 +1705,29 @@ export class ReolinkCamera
|
|
|
1647
1705
|
}
|
|
1648
1706
|
}
|
|
1649
1707
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
`
|
|
1658
|
-
|
|
1659
|
-
|
|
1708
|
+
if (thumbnailCached) {
|
|
1709
|
+
logger.debug(`Skipping already cached thumbnail: ${clip.id}`);
|
|
1710
|
+
} else {
|
|
1711
|
+
// Then get the thumbnail - this will use the local video file if
|
|
1712
|
+
// available or call the camera if the video wasn't downloaded
|
|
1713
|
+
try {
|
|
1714
|
+
await this.getVideoClipThumbnail(clip.id);
|
|
1715
|
+
logger.debug(`Downloaded thumbnail for clip: ${clip.id}`);
|
|
1716
|
+
} catch (e) {
|
|
1717
|
+
runHadError = true;
|
|
1718
|
+
logger.warn(
|
|
1719
|
+
`Failed to load thumbnail for clip ${clip.id}:`,
|
|
1720
|
+
e instanceof Error ? e.message : String(e),
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Track the most-recent successfully-handled clip for the mark.
|
|
1726
|
+
if (typeof clip.startTime === "number") {
|
|
1727
|
+
maxProcessedStart = Math.max(maxProcessedStart, clip.startTime);
|
|
1660
1728
|
}
|
|
1661
1729
|
} catch (e) {
|
|
1730
|
+
runHadError = true;
|
|
1662
1731
|
logger.warn(
|
|
1663
1732
|
`Error processing clip ${clip.id}:`,
|
|
1664
1733
|
e instanceof Error ? e.message : String(e),
|
|
@@ -1666,6 +1735,12 @@ export class ReolinkCamera
|
|
|
1666
1735
|
}
|
|
1667
1736
|
}
|
|
1668
1737
|
|
|
1738
|
+
// Advance the high-water mark only after a fully clean run, so a transient
|
|
1739
|
+
// failure forces a re-scan of the same window on the next pass.
|
|
1740
|
+
if (!runHadError && maxProcessedStart > highWaterMark) {
|
|
1741
|
+
this.storageSettings.values.videoclipsHighWaterMark = maxProcessedStart;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1669
1744
|
logger.log(`Completed auto-loading video clips and thumbnails`);
|
|
1670
1745
|
} catch (e) {
|
|
1671
1746
|
logger.error(
|
|
@@ -2277,7 +2352,8 @@ export class ReolinkCamera
|
|
|
2277
2352
|
const isDispatchEnabled = this.isEventDispatchEnabled();
|
|
2278
2353
|
|
|
2279
2354
|
logger.debug(
|
|
2280
|
-
`Baichuan event on camera (dispatch enabled: ${isDispatchEnabled})
|
|
2355
|
+
`Baichuan event on camera (dispatch enabled: ${isDispatchEnabled}):`,
|
|
2356
|
+
ev,
|
|
2281
2357
|
);
|
|
2282
2358
|
|
|
2283
2359
|
if (!isDispatchEnabled) {
|
|
@@ -3235,27 +3311,34 @@ export class ReolinkCamera
|
|
|
3235
3311
|
async getDevice(nativeId: string): Promise<any> {
|
|
3236
3312
|
if (nativeId.endsWith(motionSirenSuffix)) {
|
|
3237
3313
|
this.motionSiren ||= new ReolinkCameraMotionSiren(this, nativeId);
|
|
3314
|
+
this.restartStatusPoll();
|
|
3238
3315
|
return this.motionSiren;
|
|
3239
3316
|
} else if (nativeId.endsWith(sirenSuffix)) {
|
|
3240
3317
|
this.siren ||= new ReolinkCameraSiren(this, nativeId);
|
|
3318
|
+
this.restartStatusPoll();
|
|
3241
3319
|
return this.siren;
|
|
3242
3320
|
} else if (nativeId.endsWith(motionFloodlightSuffix)) {
|
|
3243
3321
|
this.motionFloodlight ||= new ReolinkCameraMotionFloodlight(
|
|
3244
3322
|
this,
|
|
3245
3323
|
nativeId,
|
|
3246
3324
|
);
|
|
3325
|
+
this.restartStatusPoll();
|
|
3247
3326
|
return this.motionFloodlight;
|
|
3248
3327
|
} else if (nativeId.endsWith(floodlightSuffix)) {
|
|
3249
3328
|
this.floodlight ||= new ReolinkCameraFloodlight(this, nativeId);
|
|
3329
|
+
this.restartStatusPoll();
|
|
3250
3330
|
return this.floodlight;
|
|
3251
3331
|
} else if (nativeId.endsWith(pirSuffix)) {
|
|
3252
3332
|
this.pirSensor ||= new ReolinkCameraPirSensor(this, nativeId);
|
|
3333
|
+
this.restartStatusPoll();
|
|
3253
3334
|
return this.pirSensor;
|
|
3254
3335
|
} else if (nativeId.endsWith(autotrackingSuffix)) {
|
|
3255
3336
|
this.autotracking ||= new ReolinkCameraAutotracking(this, nativeId);
|
|
3337
|
+
this.restartStatusPoll();
|
|
3256
3338
|
return this.autotracking;
|
|
3257
3339
|
} else if (nativeId.endsWith(chimeSuffix)) {
|
|
3258
3340
|
this.chime ||= new ReolinkCameraChime(this, nativeId);
|
|
3341
|
+
this.restartStatusPoll();
|
|
3259
3342
|
return this.chime;
|
|
3260
3343
|
}
|
|
3261
3344
|
}
|
|
@@ -3896,7 +3979,7 @@ export class ReolinkCamera
|
|
|
3896
3979
|
hasPlugin: !!this.plugin,
|
|
3897
3980
|
}),
|
|
3898
3981
|
);
|
|
3899
|
-
logger.debug(
|
|
3982
|
+
logger.debug(device);
|
|
3900
3983
|
} catch (e) {
|
|
3901
3984
|
logger.error(
|
|
3902
3985
|
"Failed to update device interfaces",
|
|
@@ -4427,7 +4510,7 @@ export class ReolinkCamera
|
|
|
4427
4510
|
e?.message || String(e),
|
|
4428
4511
|
);
|
|
4429
4512
|
}
|
|
4430
|
-
},
|
|
4513
|
+
}, 30_000);
|
|
4431
4514
|
}
|
|
4432
4515
|
|
|
4433
4516
|
// Update battery and snapshot every N minutes
|
|
@@ -4445,21 +4528,74 @@ export class ReolinkCamera
|
|
|
4445
4528
|
}, updateIntervalMs);
|
|
4446
4529
|
|
|
4447
4530
|
logger.log(
|
|
4448
|
-
`Periodic tasks started: sleep check every
|
|
4531
|
+
`Periodic tasks started: sleep check every 30s, battery update every ${batteryUpdateIntervalMinutes} minutes`,
|
|
4449
4532
|
);
|
|
4450
4533
|
} else {
|
|
4451
|
-
this.
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4534
|
+
this.restartStatusPoll();
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
/**
|
|
4539
|
+
* (Re)schedules the accessory status poll for wired cameras. The poll only
|
|
4540
|
+
* runs when (a) statusPollIntervalSeconds > 0 and (b) at least one accessory
|
|
4541
|
+
* device is actually instantiated — otherwise alignAuxDevicesState() would do
|
|
4542
|
+
* an ensureClient() + getAbilities() round-trip for nothing on every tick.
|
|
4543
|
+
* Called from startPeriodicTasks(), from the setting onPut, and after an
|
|
4544
|
+
* accessory child device is created (so late-created accessories get polled).
|
|
4545
|
+
*/
|
|
4546
|
+
restartStatusPoll(): void {
|
|
4547
|
+
const logger = this.getBaichuanLogger();
|
|
4548
|
+
|
|
4549
|
+
this.statusPollTimer && clearInterval(this.statusPollTimer);
|
|
4550
|
+
this.statusPollTimer = undefined;
|
|
4551
|
+
|
|
4552
|
+
// Only relevant for wired cameras; battery cameras align on wake.
|
|
4553
|
+
if (this.isBattery) return;
|
|
4461
4554
|
|
|
4462
|
-
|
|
4555
|
+
const { statusPollIntervalSeconds = 60 } = this.storageSettings.values;
|
|
4556
|
+
if (!statusPollIntervalSeconds || statusPollIntervalSeconds <= 0) {
|
|
4557
|
+
logger.log("Accessory status poll disabled (interval = 0)");
|
|
4558
|
+
return;
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
const hasAnyAccessory = (): boolean =>
|
|
4562
|
+
!!(
|
|
4563
|
+
this.motionSiren ||
|
|
4564
|
+
this.siren ||
|
|
4565
|
+
this.motionFloodlight ||
|
|
4566
|
+
this.floodlight ||
|
|
4567
|
+
this.pirSensor ||
|
|
4568
|
+
this.autotracking ||
|
|
4569
|
+
this.chime
|
|
4570
|
+
);
|
|
4571
|
+
|
|
4572
|
+
if (!hasAnyAccessory()) {
|
|
4573
|
+
// Nothing to poll yet; restartStatusPoll() is re-invoked when an
|
|
4574
|
+
// accessory device is created in getDevice().
|
|
4575
|
+
return;
|
|
4463
4576
|
}
|
|
4577
|
+
|
|
4578
|
+
const intervalMs = statusPollIntervalSeconds * 1000;
|
|
4579
|
+
|
|
4580
|
+
const run = async () => {
|
|
4581
|
+
// Guard inside the callback: accessories may be released between ticks.
|
|
4582
|
+
if (!hasAnyAccessory()) return;
|
|
4583
|
+
try {
|
|
4584
|
+
await this.alignAuxDevicesState();
|
|
4585
|
+
} catch (e) {
|
|
4586
|
+
logger.error(
|
|
4587
|
+
"Error aligning auxiliary devices state:",
|
|
4588
|
+
e?.message || String(e),
|
|
4589
|
+
);
|
|
4590
|
+
}
|
|
4591
|
+
};
|
|
4592
|
+
|
|
4593
|
+
// Stagger the first tick so many cameras don't poll in lockstep.
|
|
4594
|
+
setTimeout(run, Math.random() * intervalMs);
|
|
4595
|
+
this.statusPollTimer = setInterval(run, intervalMs);
|
|
4596
|
+
|
|
4597
|
+
logger.log(
|
|
4598
|
+
`Periodic tasks started: status poll every ${statusPollIntervalSeconds}s`,
|
|
4599
|
+
);
|
|
4464
4600
|
}
|
|
4465
4601
|
}
|
package/src/intercom.ts
CHANGED
|
@@ -57,7 +57,13 @@ export class ReolinkBaichuanIntercom {
|
|
|
57
57
|
private maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
|
|
58
58
|
private maxBacklogBytes: number | undefined;
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// PCM backlog held as a queue of chunks instead of a single growing Buffer,
|
|
61
|
+
// so the hot enqueue path no longer does a Buffer.concat per audio chunk.
|
|
62
|
+
// `pcmHeadOffset` is the number of bytes already consumed from pcmChunks[0];
|
|
63
|
+
// `pcmQueuedBytes` is the logical (unconsumed) byte length of the queue.
|
|
64
|
+
private pcmChunks: Buffer[] = [];
|
|
65
|
+
private pcmHeadOffset = 0;
|
|
66
|
+
private pcmQueuedBytes = 0;
|
|
61
67
|
|
|
62
68
|
private pumping = false;
|
|
63
69
|
private pumpPromise: Promise<void> | undefined;
|
|
@@ -134,7 +140,7 @@ export class ReolinkBaichuanIntercom {
|
|
|
134
140
|
});
|
|
135
141
|
|
|
136
142
|
this.session = session;
|
|
137
|
-
this.
|
|
143
|
+
this.resetPcmQueue();
|
|
138
144
|
this.pumping = false;
|
|
139
145
|
this.pumpPromise = undefined;
|
|
140
146
|
|
|
@@ -280,7 +286,7 @@ export class ReolinkBaichuanIntercom {
|
|
|
280
286
|
const session = this.session;
|
|
281
287
|
this.session = undefined;
|
|
282
288
|
|
|
283
|
-
this.
|
|
289
|
+
this.resetPcmQueue();
|
|
284
290
|
|
|
285
291
|
const sleepMs = async (ms: number) =>
|
|
286
292
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
@@ -329,6 +335,62 @@ export class ReolinkBaichuanIntercom {
|
|
|
329
335
|
return this.stopping;
|
|
330
336
|
}
|
|
331
337
|
|
|
338
|
+
/** Reset the PCM backlog queue. */
|
|
339
|
+
private resetPcmQueue(): void {
|
|
340
|
+
this.pcmChunks = [];
|
|
341
|
+
this.pcmHeadOffset = 0;
|
|
342
|
+
this.pcmQueuedBytes = 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Drop the oldest `dropBytes` bytes from the front of the queue, advancing
|
|
347
|
+
* the head offset and discarding fully-consumed chunks. Used by the backlog
|
|
348
|
+
* clamp; preserves the same "drop oldest samples" semantics as the previous
|
|
349
|
+
* single-buffer `subarray(length - keep)`.
|
|
350
|
+
*/
|
|
351
|
+
private dropOldestPcm(dropBytes: number): void {
|
|
352
|
+
let remaining = dropBytes;
|
|
353
|
+
while (remaining > 0 && this.pcmChunks.length) {
|
|
354
|
+
const head = this.pcmChunks[0];
|
|
355
|
+
const avail = head.length - this.pcmHeadOffset;
|
|
356
|
+
if (avail <= remaining) {
|
|
357
|
+
remaining -= avail;
|
|
358
|
+
this.pcmChunks.shift();
|
|
359
|
+
this.pcmHeadOffset = 0;
|
|
360
|
+
} else {
|
|
361
|
+
this.pcmHeadOffset += remaining;
|
|
362
|
+
remaining = 0;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
this.pcmQueuedBytes -= dropBytes - remaining;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Materialize and remove the next `bytesNeeded` contiguous bytes from the
|
|
370
|
+
* front of the queue. Returns a freshly-allocated Buffer (its own backing
|
|
371
|
+
* store, so it is safe to wrap in an Int16Array). Caller must ensure
|
|
372
|
+
* `pcmQueuedBytes >= bytesNeeded`.
|
|
373
|
+
*/
|
|
374
|
+
private takePcm(bytesNeeded: number): Buffer {
|
|
375
|
+
const out = Buffer.allocUnsafe(bytesNeeded);
|
|
376
|
+
let written = 0;
|
|
377
|
+
while (written < bytesNeeded && this.pcmChunks.length) {
|
|
378
|
+
const head = this.pcmChunks[0];
|
|
379
|
+
const avail = head.length - this.pcmHeadOffset;
|
|
380
|
+
const take = Math.min(avail, bytesNeeded - written);
|
|
381
|
+
head.copy(out, written, this.pcmHeadOffset, this.pcmHeadOffset + take);
|
|
382
|
+
written += take;
|
|
383
|
+
if (take === avail) {
|
|
384
|
+
this.pcmChunks.shift();
|
|
385
|
+
this.pcmHeadOffset = 0;
|
|
386
|
+
} else {
|
|
387
|
+
this.pcmHeadOffset += take;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.pcmQueuedBytes -= bytesNeeded;
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
332
394
|
private enqueuePcm(
|
|
333
395
|
session: Awaited<ReturnType<ReolinkBaichuanApi["createTalkSession"]>>,
|
|
334
396
|
pcmChunk: Buffer,
|
|
@@ -339,20 +401,22 @@ export class ReolinkBaichuanIntercom {
|
|
|
339
401
|
|
|
340
402
|
if (this.session !== session) return;
|
|
341
403
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
404
|
+
// Hot path: append the chunk reference, no per-chunk concat.
|
|
405
|
+
if (pcmChunk.length) {
|
|
406
|
+
this.pcmChunks.push(pcmChunk);
|
|
407
|
+
this.pcmQueuedBytes += pcmChunk.length;
|
|
408
|
+
}
|
|
345
409
|
|
|
346
410
|
// Cap backlog to keep latency bounded (drop oldest samples).
|
|
347
|
-
// IMPORTANT: do this on the shared
|
|
411
|
+
// IMPORTANT: do this on the shared queue (not in a promise chain),
|
|
348
412
|
// otherwise old PCM chunks can pile up in queued closures and bypass
|
|
349
413
|
// this clamp, causing multi-second latency and degraded audio.
|
|
350
414
|
const maxBytes = this.maxBacklogBytes ?? bytesNeeded;
|
|
351
|
-
if (this.
|
|
415
|
+
if (this.pcmQueuedBytes > maxBytes) {
|
|
352
416
|
// Align to 16-bit samples.
|
|
353
417
|
const keep = maxBytes - (maxBytes % 2);
|
|
354
|
-
const dropped = this.
|
|
355
|
-
this.
|
|
418
|
+
const dropped = this.pcmQueuedBytes - keep;
|
|
419
|
+
this.dropOldestPcm(dropped);
|
|
356
420
|
|
|
357
421
|
const now = Date.now();
|
|
358
422
|
if (now - this.lastBacklogClampLogAtMs > 2000) {
|
|
@@ -373,10 +437,9 @@ export class ReolinkBaichuanIntercom {
|
|
|
373
437
|
const encode = await loadEncodeImaAdpcm();
|
|
374
438
|
while (true) {
|
|
375
439
|
if (this.session !== session) return;
|
|
376
|
-
if (this.
|
|
440
|
+
if (this.pcmQueuedBytes < bytesNeeded) return;
|
|
377
441
|
|
|
378
|
-
const chunk = this.
|
|
379
|
-
this.pcmBuffer = this.pcmBuffer.subarray(bytesNeeded);
|
|
442
|
+
const chunk = this.takePcm(bytesNeeded);
|
|
380
443
|
|
|
381
444
|
const pcmSamples = new Int16Array(
|
|
382
445
|
chunk.buffer,
|
package/src/multiFocal.ts
CHANGED
|
@@ -140,7 +140,7 @@ export class ReolinkNativeMultiFocalDevice
|
|
|
140
140
|
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
141
141
|
|
|
142
142
|
logger.log(`Discovering lens ${lensType}`);
|
|
143
|
-
logger.debug(
|
|
143
|
+
logger.debug({ interfaces, deviceCapabilities });
|
|
144
144
|
|
|
145
145
|
const camera = await this.getDevice(nativeId);
|
|
146
146
|
|
package/src/nvr.ts
CHANGED
|
@@ -289,7 +289,7 @@ export class ReolinkNativeNvrDevice
|
|
|
289
289
|
const logger = this.getBaichuanLogger();
|
|
290
290
|
|
|
291
291
|
try {
|
|
292
|
-
logger.debug(
|
|
292
|
+
logger.debug("Baichuan event on nvr:", ev);
|
|
293
293
|
|
|
294
294
|
const channel = ev?.channel;
|
|
295
295
|
if (channel === undefined) {
|
package/src/utils.ts
CHANGED
|
@@ -135,7 +135,7 @@ export const updateDeviceInfo = async (props: {
|
|
|
135
135
|
throw e;
|
|
136
136
|
} finally {
|
|
137
137
|
logger.log(`Device info updated`);
|
|
138
|
-
logger.debug(
|
|
138
|
+
logger.debug({ newInfo: device.info, deviceData });
|
|
139
139
|
}
|
|
140
140
|
};
|
|
141
141
|
|