diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e20f6b71a..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,46 +43,49 @@ 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 --version=1.24.2 - choco install -y --no-progress gstreamer-devel --version=1.24.2 + # 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 --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) - 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 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(()) });