Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a24e22a
Revise the 1.0 design spec (revision 2)
nofuturekid Nov 12, 2026
99cba50
Bring the field ledger in line with spec revision 2
nofuturekid Nov 12, 2026
1f8bc1e
Spec revision 2: review fixes
nofuturekid Nov 12, 2026
2c1df24
Spec revision 2: second walk-through fixes
nofuturekid Nov 12, 2026
41830b3
Add the implementation plan for the groundwork stage
nofuturekid Nov 13, 2026
4534d1d
Groundwork plan: review fixes
nofuturekid Nov 13, 2026
a5529ba
Add the implementation plan for the contracts stage (W0)
nofuturekid Nov 13, 2026
f4f0907
Add guards for the groundwork move
nofuturekid Nov 13, 2026
2ab14a6
Record the package contents of 0.13.2
nofuturekid Nov 13, 2026
0f4bb43
Make the source a cargo workspace
nofuturekid Nov 13, 2026
1b66193
Move the frame codec and the on-air probe to family
nofuturekid Nov 13, 2026
e60a2a3
Split device.rs into transport/uvc.rs and enumeration.rs
nofuturekid Nov 13, 2026
3888468
Move the Tiny 2 Lite command builders and decoders to models/tiny2lite
nofuturekid Nov 13, 2026
e55a0a4
Split camera.rs into the exchange, the Tiny 2 Lite flows and the 0.13…
nofuturekid Nov 13, 2026
c249ae4
Use the final module paths and drop the re-exports
nofuturekid Nov 13, 2026
ea13cd0
Spec: an action sends exactly what was asked
nofuturekid Nov 13, 2026
e0b06ed
Spec: no stop of the daemon's own, review fixes
nofuturekid Nov 13, 2026
ae08147
Spec: presets saved without a name get none from obsbot4linux
nofuturekid Nov 13, 2026
8f2b6e3
Test the answer poll: rejected, empty, no answer, read error
nofuturekid Nov 13, 2026
f38fd14
Drop a redundant import in the answer poll tests
nofuturekid Nov 13, 2026
c0053b7
Test that a rejected tracking query gives tracking: null
nofuturekid Nov 13, 2026
e1a950d
Scripted: state only the test reason in the doc comment
nofuturekid Nov 13, 2026
f0375bc
Gimbal: make the tilt negation a pure function and test it
nofuturekid Nov 13, 2026
bee545b
Scripts: make build-arch.sh and check-readback.sh pass shellcheck
nofuturekid Nov 13, 2026
1185a55
Plan: P5 and the self-review note match the widened shellcheck gate
nofuturekid Nov 13, 2026
02ac50a
Add the tests that kill mutation survivors in carried code
nofuturekid Nov 13, 2026
37471a0
Mutation pass: review fixes (method count, open gimbal item)
nofuturekid Nov 13, 2026
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 CHANGELOG section of the Cargo.toml version, read as release.yml's verify job reads it.
# Always runs (it is cheap, and a docs-only change can edit CHANGELOG.md), so a release commit
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
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.
Loading