Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 26 additions & 30 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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: |
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 20 additions & 4 deletions src/common/camthread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();

Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions src/common/neocam.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ impl NeoCam {
config: CameraConfig,
#[cfg(feature = "pushnoti")] pn_request_tx: MpscSender<PnRequest>,
) -> Result<NeoCam> {
// 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());
Expand Down
12 changes: 12 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
41 changes: 39 additions & 2 deletions src/rtsp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<StreamKind>::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?;
Expand All @@ -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(())
});
Expand Down