From cf55091e95588c396e30be17650ac72129c52392 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 12 Oct 2026 13:49:17 +0200 Subject: [PATCH] fix(webcam): keep Logitech C270 at 30 fps in low light via udev rule Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ebcam-c270-fixed-framerate-2026-10-12.json | 43 +++++++++ .../fixes/99-c270-fixed-framerate.rules | 7 ++ linux_configuration/fixes/fix_webcam_c270.sh | 88 +++++++++++++++++++ .../tests/test_fix_webcam_c270.bats | 74 ++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 docs/superpowers/evidence/webcam-c270-fixed-framerate-2026-10-12.json create mode 100644 linux_configuration/fixes/99-c270-fixed-framerate.rules create mode 100755 linux_configuration/fixes/fix_webcam_c270.sh create mode 100755 linux_configuration/tests/test_fix_webcam_c270.bats diff --git a/docs/superpowers/evidence/webcam-c270-fixed-framerate-2026-10-12.json b/docs/superpowers/evidence/webcam-c270-fixed-framerate-2026-10-12.json new file mode 100644 index 00000000..32117981 --- /dev/null +++ b/docs/superpowers/evidence/webcam-c270-fixed-framerate-2026-10-12.json @@ -0,0 +1,43 @@ +{ + "intent": "Logitech C270 stays at 30 fps in Google Meet (Helium) in low light instead of dropping to ~15 fps and looking laggy.", + "scope": [ + "linux_configuration/fixes/fix_webcam_c270.sh", + "linux_configuration/fixes/99-c270-fixed-framerate.rules", + "linux_configuration/tests/test_fix_webcam_c270.bats", + "Non-goal: manual exposure lock; auto exposure stays on" + ], + "changes": [ + "udev rule sets exposure_dynamic_framerate=0 on the C270 capture node at every add event", + "Installer script with install/--remove/--status; installs v4l-utils if missing" + ], + "verification": [ + { + "command": "v4l2-ctl -d /dev/video0 -c exposure_dynamic_framerate=0 (live, Meet open)", + "result": "pass", + "evidence": "User confirmed Meet video no longer laggy" + }, + { + "command": "vm run c270 'fix_webcam_c270.sh; --status; --remove; --status'", + "result": "pass", + "evidence": "Rule installed then removed in vmbox, rc=0" + }, + { + "command": "v4l2-ctl -c exposure_dynamic_framerate=1 && sudo udevadm trigger --action=add --settle /sys/class/video4linux/video0", + "result": "pass", + "evidence": "Host control read back exposure_dynamic_framerate: 0 after simulated plug-in" + }, + { + "command": "bats linux_configuration/tests/test_fix_webcam_c270.bats", + "result": "pass", + "evidence": "5/5 ok" + } + ], + "risks": [ + "Darker, noisier picture in dim rooms (fixed 33 ms max exposure)", + "Chromium could start resetting this control in a future version, undoing the rule per stream" + ], + "rollback": [ + "fix_webcam_c270.sh --remove, then replug the camera", + "v4l2-ctl -d /dev/video0 -C exposure_dynamic_framerate reads the firmware value again" + ] +} diff --git a/linux_configuration/fixes/99-c270-fixed-framerate.rules b/linux_configuration/fixes/99-c270-fixed-framerate.rules new file mode 100644 index 00000000..6b2a320c --- /dev/null +++ b/linux_configuration/fixes/99-c270-fixed-framerate.rules @@ -0,0 +1,7 @@ +# Logitech C270: stop the camera halving its frame rate in dim light. +# With exposure_dynamic_framerate=1 (what the firmware comes up with) auto +# exposure stretches frames to ~60 ms, i.e. ~15 fps, and Meet looks laggy. +# Chromium/Helium never resets this control (its reset batch includes +# FOCUS_AUTO, which the C270 lacks, so the whole batch fails), so setting it +# once per plug-in is enough. Installed by fixes/fix_webcam_c270.sh. +ACTION=="add", SUBSYSTEM=="video4linux", ATTR{index}=="0", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0825", RUN+="/usr/bin/v4l2-ctl -d $devnode -c exposure_dynamic_framerate=0" diff --git a/linux_configuration/fixes/fix_webcam_c270.sh b/linux_configuration/fixes/fix_webcam_c270.sh new file mode 100755 index 00000000..9c7ab736 --- /dev/null +++ b/linux_configuration/fixes/fix_webcam_c270.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# ============================================================================ +# Logitech C270: keep a steady 30 fps in low light (udev rule) +# +# Disables exposure_dynamic_framerate on every plug-in/boot, so auto exposure +# can no longer drop the camera to ~15 fps. Trade-off: a darker image in dim +# rooms. Rationale lives in 99-c270-fixed-framerate.rules. +# +# fix_webcam_c270.sh install the rule and apply it now +# fix_webcam_c270.sh --remove remove the rule (camera default returns on replug) +# fix_webcam_c270.sh --status show rule state and the live control value +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_NAME SCRIPT_DIR +readonly RULE_NAME="99-c270-fixed-framerate.rules" +readonly RULE_SRC="$SCRIPT_DIR/$RULE_NAME" +readonly RULE_DST="/etc/udev/rules.d/$RULE_NAME" +readonly CONTROL="exposure_dynamic_framerate" +# Overridable so the tests can point it at a fake sysfs tree. +readonly V4L_SYSFS="${C270_V4L_SYSFS:-/sys/class/video4linux}" + +log() { echo "[$SCRIPT_NAME] $*"; } + +# The C270's capture node (index 0); empty when the camera is unplugged. +find_device() { + local node + for node in "$V4L_SYSFS"/video*; do + [[ -e "$node" ]] || continue + if [[ "$(cat "$node/index")" == 0 ]] && + grep -qx 0825 "$node/device/../idProduct" 2>/dev/null && + grep -qx 046d "$node/device/../idVendor" 2>/dev/null; then + echo "/dev/$(basename "$node")" + return + fi + done +} + +install_rule() { + pacman -Q v4l-utils &>/dev/null || sudo pacman -S --needed --noconfirm v4l-utils + sudo install -m 644 "$RULE_SRC" "$RULE_DST" + sudo udevadm control --reload + log "installed $RULE_DST" + local dev + dev="$(find_device)" + if [[ -n "$dev" ]]; then + v4l2-ctl -d "$dev" -c "$CONTROL=0" + log "applied to $dev now" + else + log "C270 not plugged in; rule applies on next plug-in" + fi +} + +remove_rule() { + sudo rm -f "$RULE_DST" + sudo udevadm control --reload + log "removed $RULE_DST" +} + +show_status() { + if [[ -f "$RULE_DST" ]]; then + log "rule: installed" + else + log "rule: not installed" + fi + local dev + dev="$(find_device)" + if [[ -n "$dev" ]]; then + log "$dev: $(v4l2-ctl -d "$dev" -C "$CONTROL")" + else + log "C270 not plugged in" + fi +} + +case "${1:-}" in +"") install_rule ;; +--remove) remove_rule ;; +--status) show_status ;; +-h | --help) sed -n '4,13p' "$0" | sed 's/^# \{0,3\}//' ;; +*) + log "unknown option: $1" + exit 2 + ;; +esac diff --git a/linux_configuration/tests/test_fix_webcam_c270.bats b/linux_configuration/tests/test_fix_webcam_c270.bats new file mode 100755 index 00000000..9c0fdbd3 --- /dev/null +++ b/linux_configuration/tests/test_fix_webcam_c270.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats +# Tests for fixes/fix_webcam_c270.sh against a fake sysfs tree and stubbed +# sudo/v4l2-ctl/pacman, so nothing touches /etc or a real camera. +# +# Requires: bats (extra/bats). Run with: +# bats linux_configuration/tests/test_fix_webcam_c270.bats + +bats_require_minimum_version 1.5.0 + +setup() { + REPO_DIR="$(cd -- "$(dirname -- "$BATS_TEST_FILENAME")/.." && pwd)" + SCRIPT="$REPO_DIR/fixes/fix_webcam_c270.sh" + export C270_V4L_SYSFS="$BATS_TEST_TMPDIR/v4l" + mkdir -p "$C270_V4L_SYSFS" "$BATS_TEST_TMPDIR/bin" + export CALLS="$BATS_TEST_TMPDIR/calls" + # One recording stub, linked under each tool name it stands in for. + cat >"$BATS_TEST_TMPDIR/bin/stub" <<'STUB' +#!/bin/bash +echo "$(basename "$0") $*" >>"$CALLS" +STUB + chmod +x "$BATS_TEST_TMPDIR/bin/stub" + local tool + for tool in sudo v4l2-ctl pacman; do + ln -s stub "$BATS_TEST_TMPDIR/bin/$tool" + done + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" +} + +# $1 = node name, $2 = index, $3 = vendor, $4 = product +fake_node() { + local usb="$BATS_TEST_TMPDIR/usb-$1" + mkdir -p "$usb/intf" "$C270_V4L_SYSFS/$1" + echo "$3" >"$usb/idVendor" + echo "$4" >"$usb/idProduct" + echo "$2" >"$C270_V4L_SYSFS/$1/index" + ln -s "$usb/intf" "$C270_V4L_SYSFS/$1/device" +} + +@test "install without a camera installs the rule and skips v4l2-ctl" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"not plugged in"* ]] + grep -q "sudo install -m 644 .*99-c270-fixed-framerate.rules /etc/udev/rules.d/" "$CALLS" + run ! grep -q "^v4l2-ctl" "$CALLS" +} + +@test "install applies to the C270 capture node, not its metadata node or other cams" { + fake_node video0 0 046d 0999 + fake_node video2 1 046d 0825 + fake_node video3 0 046d 0825 + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -qx "v4l2-ctl -d /dev/video3 -c exposure_dynamic_framerate=0" "$CALLS" + [ "$(grep -c '^v4l2-ctl' "$CALLS")" -eq 1 ] +} + +@test "--remove deletes the rule and reloads udev" { + run "$SCRIPT" --remove + [ "$status" -eq 0 ] + grep -qx "sudo rm -f /etc/udev/rules.d/99-c270-fixed-framerate.rules" "$CALLS" + grep -qx "sudo udevadm control --reload" "$CALLS" +} + +@test "--status reports the live control" { + fake_node video0 0 046d 0825 + run "$SCRIPT" --status + [ "$status" -eq 0 ] + grep -qx "v4l2-ctl -d /dev/video0 -C exposure_dynamic_framerate" "$CALLS" +} + +@test "unknown option exits 2" { + run "$SCRIPT" --bogus + [ "$status" -eq 2 ] +}