From e9444fa0093087e89ec27ef4d0ad708f18a8ba9e Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Fri, 17 Apr 2026 13:37:30 -0700 Subject: [PATCH 1/4] feat: battery-aware reconnect backoff to prevent battery drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Battery cameras (Reolink Argus, E1 Outdoor, etc.) suffer severe battery drain when neolink retries connections every 5 seconds indefinitely. Each retry wakes the camera via the relay, draining batteries at ~240 wake-ups per hour per camera. Changes: - Add `battery_camera` config option per camera (`battery_camera = true`) - True exponential backoff for battery cameras: 50ms → doubling → 1hr cap (vs the fixed 5s cap that causes the drain) - Disable 15-second stream-info poll for battery cameras; query once at startup since stream capabilities don't change at runtime - Auto-enable `idle_disconnect` for battery cameras so the connection drops when no RTSP client is viewing Example config: [[cameras]] name = "front_door" battery_camera = true username = "admin" password = "****" uid = "XXXXXXXXXX" Fixes #204, addresses discussions #241 and #334. --- src/common/camthread.rs | 24 ++++++++++++++++++++---- src/common/neocam.rs | 14 ++++++++++++++ src/config.rs | 12 ++++++++++++ src/rtsp/mod.rs | 41 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/common/camthread.rs b/src/common/camthread.rs index 7bdbd70f0..b665c7e6b 100644 --- a/src/common/camthread.rs +++ b/src/common/camthread.rs @@ -105,8 +105,14 @@ impl NeoCamThread { // A watch sender is used to send the new camera // whenever it changes pub(crate) async fn run(&mut self) -> AnyResult<()> { - const MAX_BACKOFF: Duration = Duration::from_secs(5); const MIN_BACKOFF: Duration = Duration::from_millis(50); + // Default cap for wired cameras (backward-compatible with previous 5s cap, + // but slightly more generous to reduce log noise) + const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(5); + // For battery cameras: allow true exponential growth up to 1 hour. + // A sleeping battery camera won't respond until motion wakes it, + // so hammering it every 5 seconds just drains the battery. + const BATTERY_MAX_BACKOFF: Duration = Duration::from_secs(3600); let mut backoff = MIN_BACKOFF; @@ -120,6 +126,12 @@ impl NeoCamThread { let config = config_rec.borrow_and_update().clone(); let now = Instant::now(); let name = config.name.clone(); + let is_battery = config.battery_camera; + let max_backoff = if is_battery { + BATTERY_MAX_BACKOFF + } else { + DEFAULT_MAX_BACKOFF + }; let mut state = self.state.clone(); @@ -152,8 +164,8 @@ impl NeoCamThread { // Command ran long enough to be considered a success backoff = MIN_BACKOFF; } - if backoff > MAX_BACKOFF { - backoff = MAX_BACKOFF; + if backoff > max_backoff { + backoff = max_backoff; } match result { @@ -177,7 +189,11 @@ impl NeoCamThread { _ => { // Non fatal log::warn!("{name}: Connection Lost: {:?}", e); - log::info!("{name}: Attempt reconnect in {:?}", backoff); + log::info!( + "{name}: Attempt reconnect in {:?}{}", + backoff, + if is_battery { " (battery camera)" } else { "" } + ); sleep(backoff).await; backoff *= 2; } diff --git a/src/common/neocam.rs b/src/common/neocam.rs index 8633eedd9..78594758b 100644 --- a/src/common/neocam.rs +++ b/src/common/neocam.rs @@ -56,6 +56,20 @@ impl NeoCam { config: CameraConfig, #[cfg(feature = "pushnoti")] pn_request_tx: MpscSender, ) -> Result { + // Battery cameras implicitly enable idle_disconnect to prevent + // keeping the camera awake when no RTSP client is connected. + let config = if config.battery_camera && !config.idle_disconnect { + log::info!( + "{}: Battery camera detected, auto-enabling idle_disconnect", + config.name + ); + let mut c = config; + c.idle_disconnect = true; + c + } else { + config + }; + let (commander_tx, commander_rx) = mpsc(100); let (watch_config_tx, watch_config_rx) = watch(config.clone()); let (camera_watch_tx, camera_watch_rx) = watch(Weak::new()); diff --git a/src/config.rs b/src/config.rs index 6ca22ad2c..716f4d909 100644 --- a/src/config.rs +++ b/src/config.rs @@ -218,6 +218,18 @@ pub(crate) struct CameraConfig { #[serde(default = "default_false", alias = "idle", alias = "idle_disc")] pub(crate) idle_disconnect: bool, + + /// Mark this camera as battery-powered. + /// + /// When true, neolink will: + /// - Auto-enable `idle_disconnect` behavior + /// - Use true exponential backoff on reconnection (doubling up to 1 hour) + /// instead of the default 5-second cap, to avoid draining the battery + /// with constant reconnection attempts + /// - Disable the periodic stream-info poll that would otherwise wake the + /// camera every 15 seconds; stream capabilities are queried once instead + #[serde(default = "default_false", alias = "battery")] + pub(crate) battery_camera: bool, } #[derive(Debug, Deserialize, Serialize, Validate, Clone, PartialEq, Eq, Hash)] diff --git a/src/rtsp/mod.rs b/src/rtsp/mod.rs index aa82d9792..603c53273 100644 --- a/src/rtsp/mod.rs +++ b/src/rtsp/mod.rs @@ -256,10 +256,43 @@ async fn camera_main(camera: NeoInstance, rtsp: &NeoRtspServer) -> Result<()> { let later_camera = camera.clone(); let (supported_streams_tx, supported_streams) = watch(HashSet::::new()); + // Check if this is a battery camera so we can adjust polling behavior + let camera_config = camera.config().await?.clone(); + let is_battery = camera_config.borrow().battery_camera; + let mut set = JoinSet::new(); set.spawn(async move { - let mut i = IntervalStream::new(interval(Duration::from_secs(15))); - while i.next().await.is_some() { + // Battery cameras: query stream info once at startup, then stop. + // The 15-second poll wakes the camera via the relay every time, + // draining the battery rapidly. Stream capabilities don't change + // at runtime, so a single query is sufficient. + // + // Wired cameras: poll every 15 seconds (original behavior). + let poll_interval = if is_battery { + None // No repeated polling + } else { + Some(Duration::from_secs(15)) + }; + + // Always do at least one query + let mut first = true; + let mut ticker = poll_interval.map(|d| IntervalStream::new(interval(d))); + + loop { + if first { + first = false; + } else if let Some(ref mut t) = ticker { + if t.next().await.is_none() { + break; + } + } else { + // Battery camera: we already did the one-time query, just + // keep this task alive (it holds the supported_streams_tx) + // so the stream setup code can read it. + futures::future::pending::<()>().await; + break; + } + let stream_info = later_camera .run_passive_task(|cam| Box::pin(async move { Ok(cam.get_stream_info().await?) })) .await?; @@ -286,6 +319,10 @@ async fn camera_main(camera: NeoInstance, rtsp: &NeoRtspServer) -> Result<()> { false } }); + + if is_battery { + log::info!("Battery camera: stream info acquired, disabling periodic poll"); + } } AnyResult::Ok(()) }); From 3f36971215e84f261375f77ed374b2ea43b13e0b Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Fri, 17 Apr 2026 14:14:42 -0700 Subject: [PATCH 2/4] ci: unpin Windows choco dependency versions The pinned versions (gstreamer 1.24.2, protoc 24.2.0, openssl 1.1.1.2100) are no longer available on Chocolatey, causing all Windows CI builds to fail at the 'Install Windows deps' step. Remove version pins to use latest available packages. --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e20f6b71a..e93e6a1f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,8 +45,8 @@ jobs: name: Install Windows deps run: | # Gstreamer - choco install -y --no-progress gstreamer --version=1.24.2 - choco install -y --no-progress gstreamer-devel --version=1.24.2 + choco install -y --no-progress gstreamer + choco install -y --no-progress gstreamer-devel $env:GSTREAMER_1_0_ROOT_MSVC_X86_64=$env:SYSTEMDRIVE + '\gstreamer\1.0\msvc_x86_64\' # Github runners work on both C or D drive and figuring out which was used is difficult if (-not (Test-Path -Path "$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" -PathType Container)) { @@ -55,10 +55,10 @@ jobs: echo "GSTREAMER_1_0_ROOT_MSVC_X86_64=$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" # Proto buffers - choco install -y --no-progress protoc --version=24.2.0 + choco install -y --no-progress protoc # Open SSL - choco install -y --no-progress openssl --version=1.1.1.2100 + choco install -y --no-progress openssl $env:OPENSSL_DIR=$env:SYSTEMDRIVE + '\Program Files\OpenSSL-Win64\' # Alternative openssl location (depends on version that gets installed by choco) From 65d2e0db3375fbb689a31a62c0d61fa36b4b9da1 Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Fri, 17 Apr 2026 15:14:26 -0700 Subject: [PATCH 3/4] ci: add dedicated Windows build workflow Bypasses the unreliable choco gstreamer package by downloading MSIs directly from gstreamer.freedesktop.org. Also avoids the macOS-12 runner that's stuck in queue (deprecated). --- .github/workflows/build-windows.yml | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 000000000..8174cada4 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,63 @@ +name: Windows Build + +on: + workflow_dispatch: + +env: + rust_version: "1.82.0" + +jobs: + build-windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + + - name: Install deps + shell: pwsh + run: | + # Download GStreamer MSIs directly (choco is unreliable for GStreamer) + $GST_VER = "1.24.12" + $BASE_URL = "https://gstreamer.freedesktop.org/data/pkg/windows/$GST_VER/msvc" + + Write-Host "Downloading GStreamer $GST_VER runtime..." + Invoke-WebRequest -Uri "$BASE_URL/gstreamer-1.0-msvc-x86_64-$GST_VER.msi" -OutFile gst-runtime.msi + Write-Host "Downloading GStreamer $GST_VER devel..." + Invoke-WebRequest -Uri "$BASE_URL/gstreamer-1.0-devel-msvc-x86_64-$GST_VER.msi" -OutFile gst-devel.msi + + Write-Host "Installing GStreamer runtime..." + Start-Process msiexec.exe -ArgumentList "/i gst-runtime.msi ADDLOCAL=ALL /qn /norestart" -Wait -NoNewWindow + Write-Host "Installing GStreamer devel..." + Start-Process msiexec.exe -ArgumentList "/i gst-devel.msi ADDLOCAL=ALL /qn /norestart" -Wait -NoNewWindow + + # Set env vars + $gstRoot = "$env:SYSTEMDRIVE\gstreamer\1.0\msvc_x86_64\" + if (-not (Test-Path $gstRoot)) { $gstRoot = "D:\gstreamer\1.0\msvc_x86_64\" } + echo "GSTREAMER_1_0_ROOT_MSVC_X86_64=$gstRoot" >> $env:GITHUB_ENV + echo "$gstRoot\bin" >> $env:GITHUB_PATH + + # Install protoc + choco install -y --no-progress protoc + + # Install OpenSSL + choco install -y --no-progress openssl + $sslDir = "$env:SYSTEMDRIVE\Program Files\OpenSSL-Win64\" + if (-not (Test-Path $sslDir)) { $sslDir = "$env:SYSTEMDRIVE\Program Files\OpenSSL\" } + echo "OPENSSL_DIR=$sslDir" >> $env:GITHUB_ENV + + # Verify + Write-Host "PKG_CONFIG_PATH will be: $gstRoot\lib\pkgconfig" + echo "PKG_CONFIG_PATH=$gstRoot\lib\pkgconfig" >> $env:GITHUB_ENV + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.rust_version }} + + - name: Build release + run: cargo build --release + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: neolink-windows + path: target/release/neolink.exe From 4d09591b8c2061275f1a05402575d3b9b6a391ca Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Fri, 17 Apr 2026 15:20:11 -0700 Subject: [PATCH 4/4] ci: fix Windows build and drop deprecated macOS-12 runner - Download GStreamer MSIs directly from gstreamer.freedesktop.org instead of using unreliable choco gstreamer/gstreamer-devel packages - Drop macOS-12 from CI matrix (deprecated, stuck in queue) - Set PKG_CONFIG_PATH for Windows builds - Remove macos-12 artifact references from release job --- .github/workflows/build-windows.yml | 63 ----------------------------- .github/workflows/build.yml | 52 +++++++++++------------- 2 files changed, 24 insertions(+), 91 deletions(-) delete mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml deleted file mode 100644 index 8174cada4..000000000 --- a/.github/workflows/build-windows.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Windows Build - -on: - workflow_dispatch: - -env: - rust_version: "1.82.0" - -jobs: - build-windows: - runs-on: windows-2022 - steps: - - uses: actions/checkout@v4 - - - name: Install deps - shell: pwsh - run: | - # Download GStreamer MSIs directly (choco is unreliable for GStreamer) - $GST_VER = "1.24.12" - $BASE_URL = "https://gstreamer.freedesktop.org/data/pkg/windows/$GST_VER/msvc" - - Write-Host "Downloading GStreamer $GST_VER runtime..." - Invoke-WebRequest -Uri "$BASE_URL/gstreamer-1.0-msvc-x86_64-$GST_VER.msi" -OutFile gst-runtime.msi - Write-Host "Downloading GStreamer $GST_VER devel..." - Invoke-WebRequest -Uri "$BASE_URL/gstreamer-1.0-devel-msvc-x86_64-$GST_VER.msi" -OutFile gst-devel.msi - - Write-Host "Installing GStreamer runtime..." - Start-Process msiexec.exe -ArgumentList "/i gst-runtime.msi ADDLOCAL=ALL /qn /norestart" -Wait -NoNewWindow - Write-Host "Installing GStreamer devel..." - Start-Process msiexec.exe -ArgumentList "/i gst-devel.msi ADDLOCAL=ALL /qn /norestart" -Wait -NoNewWindow - - # Set env vars - $gstRoot = "$env:SYSTEMDRIVE\gstreamer\1.0\msvc_x86_64\" - if (-not (Test-Path $gstRoot)) { $gstRoot = "D:\gstreamer\1.0\msvc_x86_64\" } - echo "GSTREAMER_1_0_ROOT_MSVC_X86_64=$gstRoot" >> $env:GITHUB_ENV - echo "$gstRoot\bin" >> $env:GITHUB_PATH - - # Install protoc - choco install -y --no-progress protoc - - # Install OpenSSL - choco install -y --no-progress openssl - $sslDir = "$env:SYSTEMDRIVE\Program Files\OpenSSL-Win64\" - if (-not (Test-Path $sslDir)) { $sslDir = "$env:SYSTEMDRIVE\Program Files\OpenSSL\" } - echo "OPENSSL_DIR=$sslDir" >> $env:GITHUB_ENV - - # Verify - Write-Host "PKG_CONFIG_PATH will be: $gstRoot\lib\pkgconfig" - echo "PKG_CONFIG_PATH=$gstRoot\lib\pkgconfig" >> $env:GITHUB_ENV - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.rust_version }} - - - name: Build release - run: cargo build --release - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: neolink-windows - path: target/release/neolink.exe diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e93e6a1f4..50385319b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, windows-2022, macos-12, macos-14] # macos-14+ is arm64 + os: [ubuntu-22.04, windows-2022, macos-14] # macos-14+ is arm64 steps: - uses: actions/checkout@v4 name: Checkout onto ${{ runner.os }} @@ -43,16 +43,27 @@ jobs: sudo aptitude install -y libgstrtspserver-1.0-dev libgstreamer1.0-dev libgtk2.0-dev protobuf-compiler libssl-dev - if: runner.os == 'Windows' name: Install Windows deps + shell: pwsh run: | - # Gstreamer - choco install -y --no-progress gstreamer - choco install -y --no-progress gstreamer-devel + # GStreamer - download MSIs directly (choco gstreamer package is unreliable) + $GST_VER = "1.24.12" + $BASE_URL = "https://gstreamer.freedesktop.org/data/pkg/windows/$GST_VER/msvc" + + Write-Host "Downloading GStreamer $GST_VER runtime..." + Invoke-WebRequest -Uri "$BASE_URL/gstreamer-1.0-msvc-x86_64-$GST_VER.msi" -OutFile gst-runtime.msi + Write-Host "Downloading GStreamer $GST_VER devel..." + Invoke-WebRequest -Uri "$BASE_URL/gstreamer-1.0-devel-msvc-x86_64-$GST_VER.msi" -OutFile gst-devel.msi + + Write-Host "Installing GStreamer runtime..." + Start-Process msiexec.exe -ArgumentList "/i gst-runtime.msi ADDLOCAL=ALL /qn /norestart" -Wait -NoNewWindow + Write-Host "Installing GStreamer devel..." + Start-Process msiexec.exe -ArgumentList "/i gst-devel.msi ADDLOCAL=ALL /qn /norestart" -Wait -NoNewWindow + $env:GSTREAMER_1_0_ROOT_MSVC_X86_64=$env:SYSTEMDRIVE + '\gstreamer\1.0\msvc_x86_64\' - # Github runners work on both C or D drive and figuring out which was used is difficult if (-not (Test-Path -Path "$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" -PathType Container)) { - $env:GSTREAMER_1_0_ROOT_MSVC_X86_64='D:\\gstreamer\1.0\msvc_x86_64\' + $env:GSTREAMER_1_0_ROOT_MSVC_X86_64='D:\gstreamer\1.0\msvc_x86_64\' } - echo "GSTREAMER_1_0_ROOT_MSVC_X86_64=$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" + Write-Host "GSTREAMER_1_0_ROOT_MSVC_X86_64=$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" # Proto buffers choco install -y --no-progress protoc @@ -60,29 +71,21 @@ jobs: # Open SSL choco install -y --no-progress openssl $env:OPENSSL_DIR=$env:SYSTEMDRIVE + '\Program Files\OpenSSL-Win64\' - - # Alternative openssl location (depends on version that gets installed by choco) - if (-not (Test-Path -Path "$env:OPENSSL_DIR" -PathType Container)) { + if (-not (Test-Path "$env:OPENSSL_DIR" -PathType Container)) { $env:OPENSSL_DIR=$env:SYSTEMDRIVE + '\Program Files\OpenSSL\' } - # Github runners work on both C or D drive and figuring out which was used is difficult - if (-not (Test-Path -Path "$env:OPENSSL_DIR" -PathType Container)) { - $env:OPENSSL_DIR='D:\\Program Files\OpenSSL-Win64\' + if (-not (Test-Path "$env:OPENSSL_DIR" -PathType Container)) { + $env:OPENSSL_DIR='D:\Program Files\OpenSSL-Win64\' } - # Or course we could be on alternative location and drive.... - if (-not (Test-Path -Path "$env:OPENSSL_DIR" -PathType Container)) { - $env:OPENSSL_DIR='D:\\Program Files\OpenSSL\' + if (-not (Test-Path "$env:OPENSSL_DIR" -PathType Container)) { + $env:OPENSSL_DIR='D:\Program Files\OpenSSL\' } # Set github vars Add-Content -Path $env:GITHUB_ENV -Value "GSTREAMER_1_0_ROOT_MSVC_X86_64=$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" Add-Content -Path $env:GITHUB_PATH -Value "$env:GSTREAMER_1_0_ROOT_MSVC_X86_64\bin" - Add-Content -Path $env:GITHUB_PATH -Value "%GSTREAMER_1_0_ROOT_MSVC_X86_64%\bin" Add-Content -Path $env:GITHUB_ENV -Value "OPENSSL_DIR=$env:OPENSSL_DIR" - - # One last check on directories - dir "$env:GSTREAMER_1_0_ROOT_MSVC_X86_64" - dir "$env:OPENSSL_DIR" + Add-Content -Path $env:GITHUB_ENV -Value "PKG_CONFIG_PATH=$env:GSTREAMER_1_0_ROOT_MSVC_X86_64\lib\pkgconfig" - if: runner.os == 'macOS' name: Install macOS deps run: | @@ -363,11 +366,6 @@ jobs: with: name: release-windows-2022 path: neolink_windows - - name: Download Macos - uses: actions/download-artifact@v4 - with: - name: release-macos-12 - path: neolink_macos_intel - name: Download Macos arm64 uses: actions/download-artifact@v4 with: @@ -402,7 +400,6 @@ jobs: run: | dirs=( neolink_windows - neolink_macos_intel neolink_macos_m1 neolink_linux_x86_64_ubuntu neolink_linux_x86_64_bookworm @@ -420,7 +417,6 @@ jobs: name: Neolink ${{steps.toml.outputs.version}} files: | neolink_windows.zip - neolink_macos_intel.zip neolink_macos_m1.zip neolink_linux_x86_64_ubuntu.zip neolink_linux_x86_64_bookworm.zip