Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
with:
persist-credentials: false
- run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component clippy --no-self-update
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo clippy --workspace --all-targets --locked -- -D warnings

test:
needs: changes
Expand All @@ -79,7 +79,7 @@ jobs:
with:
persist-credentials: false
- run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --no-self-update
- run: cargo test --locked
- run: cargo test --workspace --locked

# Builds and tests with the oldest Rust that Cargo.toml's rust-version claims.
msrv:
Expand All @@ -94,7 +94,7 @@ jobs:
msrv=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml)
test -n "$msrv"
rustup toolchain install "$msrv" --profile minimal --no-self-update
cargo "+$msrv" test --locked
cargo "+$msrv" test --workspace --locked

# The one check the main ruleset requires. Code changes need fmt, clippy, test and msrv to
# succeed; docs-only changes need them skipped. Anything else fails.
Expand Down
63 changes: 57 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ on:
- scripts/**
- Cargo.toml
- Cargo.lock
- crates/*/Cargo.toml
- README.md
- CHANGELOG.md
- LICENSE
Expand Down Expand Up @@ -115,13 +116,13 @@ jobs:
rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal
--component rustfmt --component clippy --no-self-update
- run: cargo fmt --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
- run: cargo test --workspace --locked
- run: |
msrv=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml)
test -n "$msrv"
rustup toolchain install "$msrv" --profile minimal --no-self-update
cargo "+$msrv" test --locked
cargo "+$msrv" test --workspace --locked

# One job per package format and architecture: a failure reruns only that job.
binary:
Expand Down Expand Up @@ -200,7 +201,7 @@ jobs:
mkdir dist
# no rebuild: package the binary from the binary job (artifacts drop the mode)
chmod +x target/release/obsbot4linux
cargo deb --no-build --deb-revision "" -o "dist/obsbot4linux_${ver}_$DEB.deb"
cargo deb -p obsbot4linux --no-build --deb-revision "" -o "dist/obsbot4linux_${ver}_$DEB.deb"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dist-deb-${{ matrix.arch }}
Expand Down Expand Up @@ -237,7 +238,7 @@ jobs:
ver=$VER
mkdir dist
chmod +x target/release/obsbot4linux
cargo generate-rpm -o "dist/obsbot4linux-$ver-1.$ARCH.rpm"
cargo generate-rpm -p crates/obsbot4linux -o "dist/obsbot4linux-$ver-1.$ARCH.rpm"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dist-rpm-${{ matrix.arch }}
Expand Down Expand Up @@ -276,10 +277,60 @@ jobs:
path: dist/*
if-no-files-found: error

# Lists the files of every package and compares the list with packaging/contents.txt, so that
# a change to what a package holds shows up in review. A release needs it too.
contents:
needs: [version, binary, deb, rpm, arch]
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: dist
pattern: dist-*
merge-multiple: true
- run: sudo apt-get install -y --no-install-recommends libarchive-tools
# [profile.release] strip = true must reach every package (cargo ignores a member
# manifest's profile, and the file list alone would not show it)
- name: The packaged binaries are stripped
run: |
for pkg in dist/obsbot4linux*; do
case $pkg in
*.deb) bsdtar -xOf "$pkg" 'data.tar*' > payload ;;
*) cp "$pkg" payload ;;
esac
bin=$(bsdtar -tf payload | grep -E '(usr/bin|-linux)/obsbot4linux$')
bsdtar -xOf payload "$bin" > obsbot4linux.bin
info=$(file -b obsbot4linux.bin)
echo "$pkg: $info"
case $info in
*', stripped') ;;
*) echo "::error::$pkg ships an unstripped binary"; exit 1 ;;
esac
done
rm -f payload obsbot4linux.bin
- name: Compare the package contents
env:
VER: ${{ needs.version.outputs.ver }}
run: |
scripts/package-contents.sh dist "$VER" > contents.txt
cat contents.txt
diff -u packaging/contents.txt contents.txt
# the list itself, also when it differs, to update packaging/contents.txt from
- if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: package-contents
path: contents.txt
if-no-files-found: error

# Tags only: a pull request builds the packages above and publishes nothing.
release:
if: startsWith(github.ref, 'refs/tags/')
needs: [binary, deb, rpm, arch]
needs: [binary, deb, rpm, arch, contents]
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions:
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ All notable changes to OBSBot4Linux. The format follows

- The repository moved to github.com/hellwand/obsbot4linux on 2026-11-12; the old address redirects (the link change itself landed in #109).
- README and package text now say "most settings OBSBOT Center offers" and link `docs/center-parity.md` for the gaps; they also state the glibc 2.34 floor, how to switch to the socket units while the widget's daemon runs, to restart the daemon after an upgrade (socket users: `systemctl --user restart obsbot4linux.service`; a daemon started by `watch` ends with the shell's unit), that `zoom F --speed S` and `zoom stop` leave the AI mode alone, the `device busy` window of the on-air probe on uvcvideo up to 6.16, and the order of auto and manual image controls (#121).
- The source is a cargo workspace now: `crates/obsbot-device` holds the camera access,
`crates/obsbot4linux` the CLI and the daemon. The program, its output and the packages do not
change.

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Every change goes through an issue and a pull request, also the maintainer's:

1. Open an issue: what is wrong or missing, how to reproduce it, which camera and firmware.
2. Open a pull request against `main` whose description starts with `Fixes #<issue>`.
3. CI must be green (`cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`,
`cargo test`, and `cargo test` again with the `rust-version` from `Cargo.toml`).
3. CI must be green (`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings`,
`cargo test --workspace`, and `cargo test --workspace` again with the `rust-version` from `Cargo.toml`).
Changes that only touch docs (Markdown files, `LICENSE`, `docs/`) skip those four; the
`required` check still reports. No changelog entry is enforced per pull request; the release
tag needs its `CHANGELOG.md` section.
Expand Down
13 changes: 12 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 8 additions & 40 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,57 +1,25 @@
[package]
name = "obsbot4linux"
[workspace]
resolver = "3"
members = ["crates/obsbot-device", "crates/obsbot4linux"]

[workspace.package]
version = "0.13.2"
edition = "2024"
rust-version = "1.88"
description = "Control OBSBOT webcams on Linux from the console and the DMS widget"
authors = ["Thomas Kroll"]
license = "GPL-3.0-or-later"
repository = "https://github.com/hellwand/obsbot4linux"
homepage = "https://github.com/hellwand/obsbot4linux"
readme = "README.md"
publish = false

[lib]
name = "obsbot4linux"
path = "src/lib.rs"

[[bin]]
name = "obsbot4linux"
path = "src/main.rs"

[dependencies]
[workspace.dependencies]
obsbot-device = { path = "crates/obsbot-device" }
clap = { version = "4", features = ["derive"] }
nix = { version = "0.31", features = ["fs", "ioctl"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
hex = "0.4"

[profile.release]
strip = true

[dev-dependencies]
hex = "0.4"

[package.metadata.deb]
maintainer = "Thomas Kroll <t.kroll@outlook.com>"
section = "video"
extended-description = "Most settings OBSBOT Center offers, from the console and from the DMS bar widget.\nWhat is missing: https://github.com/hellwand/obsbot4linux/blob/main/docs/center-parity.md\nThe obsbot4linux daemon (systemd user socket) owns the camera state."
depends = "$auto"
license-file = ["LICENSE", "0"]
assets = [
["target/release/obsbot4linux", "usr/bin/", "755"],
["packaging/systemd/obsbot4linux.service", "usr/lib/systemd/user/", "644"],
["packaging/systemd/obsbot4linux.socket", "usr/lib/systemd/user/", "644"],
["README.md", "usr/share/doc/obsbot4linux/", "644"],
["CHANGELOG.md", "usr/share/doc/obsbot4linux/", "644"],
]

[package.metadata.generate-rpm]
assets = [
{ source = "target/release/obsbot4linux", dest = "/usr/bin/obsbot4linux", mode = "755" },
{ source = "packaging/systemd/obsbot4linux.service", dest = "/usr/lib/systemd/user/obsbot4linux.service", mode = "644" },
{ source = "packaging/systemd/obsbot4linux.socket", dest = "/usr/lib/systemd/user/obsbot4linux.socket", mode = "644" },
{ source = "README.md", dest = "/usr/share/doc/obsbot4linux/README.md", mode = "644", doc = true },
{ source = "CHANGELOG.md", dest = "/usr/share/doc/obsbot4linux/CHANGELOG.md", mode = "644", doc = true },
{ source = "LICENSE", dest = "/usr/share/licenses/obsbot4linux/LICENSE", mode = "644" },
]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ it does not arm the flag but consumes one that a recall or `0x6604` armed earlie
goes Static on arrival; afterwards tracking may stay suspended, since the view's actions name no
AI mode, as with `startup go`. Not seen so far: tracking stayed on after a `zone view go` in AI normal. The flag part is still to be confirmed.

Adding a model: an entry in `MODELS` in `src/models.rs` (name, USB id, by-id and card name
Adding a model: an entry in `MODELS` in `crates/obsbot-device/src/registry.rs` (name, USB id, by-id and card name
patterns) makes it detected. Its commands and status decoding differ per camera and today exist
for the Tiny 2 Lite only; the plan is one module per model behind the same commands, once a
camera is at hand to capture and test (see [CONTRIBUTING.md](CONTRIBUTING.md)).
Expand Down
21 changes: 21 additions & 0 deletions crates/obsbot-device/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "obsbot-device"
description = "Camera access for obsbot4linux: transport, frame codec and the camera models"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
publish.workspace = true

[dependencies]
clap.workspace = true
nix.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

[dev-dependencies]
hex.workspace = true
File renamed without changes.
File renamed without changes.
118 changes: 118 additions & 0 deletions crates/obsbot-device/src/enumeration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5.

//! Finding a supported camera without talking to it: `/dev/v4l/by-id` links first, the V4L2
//! card name as fallback.

use crate::registry::{self, Model};
use nix::ioctl_read;
use std::fs::{self, File};
use std::os::fd::AsRawFd;
use std::path::PathBuf;

const V4L2_CAP_META_CAPTURE: u32 = 0x0080_0000;

#[repr(C)]
#[derive(Default)]
struct V4l2Capability {
driver: [u8; 16],
card: [u8; 32],
bus_info: [u8; 32],
version: u32,
capabilities: u32,
device_caps: u32,
reserved: [u32; 3],
}

ioctl_read!(vidioc_querycap, b'V', 0, V4l2Capability);

/// A camera found by enumeration, before any command is sent to it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detected {
pub model: &'static Model,
/// The V4L2 node to stream from: the stable `/dev/v4l/by-id/...-video-index0` link when
/// there is one, else `/dev/videoN`.
pub device: PathBuf,
}

fn card_name(file: &File) -> Option<(String, u32)> {
let mut cap = V4l2Capability::default();
unsafe { vidioc_querycap(file.as_raw_fd(), &mut cap) }.ok()?;
let end = cap
.card
.iter()
.position(|b| *b == 0)
.unwrap_or(cap.card.len());
Some((
String::from_utf8_lossy(&cap.card[..end]).into_owned(),
cap.device_caps,
))
}

/// Finds a supported camera without talking to it: by-id symlink first, V4L2 card name
/// (VIDIOC_QUERYCAP, a read-only ioctl) as fallback.
pub fn detect() -> Option<Detected> {
let by_id = fs::read_dir("/dev/v4l/by-id")
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
if let Some(detected) = first_by_id(by_id) {
return Some(detected);
}

let mut paths: Vec<PathBuf> = fs::read_dir("/dev")
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("video"))
})
.collect();
paths.sort();
for path in paths {
if let Ok(file) = File::open(&path)
&& let Some((card, caps)) = card_name(&file)
&& caps & V4L2_CAP_META_CAPTURE == 0
&& let Some(model) = registry::from_card(&card)
{
return Some(Detected {
model,
device: path,
});
}
}
None
}

/// The first supported camera among the by-id links, sorted: `read_dir` order is unspecified,
/// so with two cameras the controlled one could change between runs.
fn first_by_id(mut links: Vec<PathBuf>) -> Option<Detected> {
links.sort();
links.into_iter().find_map(|device| {
let model = registry::from_by_id(&device.file_name()?.to_string_lossy())?;
Some(Detected { model, device })
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn by_id_detection_does_not_depend_on_directory_order() {
let link = |serial: &str| {
PathBuf::from(format!(
"/dev/v4l/by-id/usb-Remo_Tech_Co.__Ltd._OBSBOT_Tiny_2_Lite_{serial}-video-index0"
))
};
let other = PathBuf::from("/dev/v4l/by-id/usb-Logitech_BRIO-video-index0");
let picked = first_by_id(vec![other.clone(), link("B"), link("A")]).unwrap();
assert_eq!(picked.device, link("A"));
let picked = first_by_id(vec![link("A"), link("B"), other]).unwrap();
assert_eq!(picked.device, link("A"));
assert_eq!(first_by_id(Vec::new()), None);
}
}
File renamed without changes.
File renamed without changes.
Loading