feat(hardware): hide dual-sensor webcam IR node (fixes dark image)
A built-in RGB+IR webcam (common on recent ThinkPads, e.g. the T14s AMD Gen 4) exposes its IR face-unlock sensor as a second, identically-named "Integrated Camera"; apps that pick it show a dark, 8-bit-greyscale image. nomarchy.hardware.camera.hideIrSensor disables that node on the V4L2 PipeWire path (WirePlumber monitor.v4l2.rules) so only the colour camera is offered, matched by card name (overridable irMatch). - libcamera is left untouched: an external camera you plug in is never affected, and surgical internal-only libcamera scoping isn't possible (its distinguishing props bind after the monitor rule runs). - Only the PipeWire node is hidden; the kernel /dev/video* stays open, so Howdy face-unlock still reads the IR sensor directly. - The installer's hardware-db.sh auto-detects a paired RGB+IR webcam from /sys/class/video4linux/*/name and bakes the toggle into system.nix; commented example added to the downstream template. Validated live on a T14s: collapses the four enumerated "Integrated Camera" entries to one colour source; the exact shipped regex disables the IR node (confirmed in the WirePlumber log) and leaves libcamera intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -428,6 +428,74 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
distro-wide regardless).
|
distro-wide regardless).
|
||||||
Sub-items here can graduate into their own roadmap entries as they're
|
Sub-items here can graduate into their own roadmap entries as they're
|
||||||
scoped; the unifying work is the detection + `nomarchy.hardware.*` surface.
|
scoped; the unifying work is the detection + `nomarchy.hardware.*` surface.
|
||||||
|
- **Webcam support & tuning:** improve out-of-the-box webcam behaviour.
|
||||||
|
Motivating case — on the ThinkPad T14s AMD Gen 4 the camera shows a dark,
|
||||||
|
low-quality image. **Diagnosed on hardware (2026-06-26)**, and it's *not* what
|
||||||
|
the first cut of this item guessed (UVC default-controls tuning vs a MIPI/
|
||||||
|
libcamera gap):
|
||||||
|
- The camera is a **USB UVC** module (Chicony `04f2:b7c0`, `uvcvideo`) and is
|
||||||
|
**dual-sensor** — `video0` Color (MJPG, up to 2592×1944 / 1080p) + `video2`
|
||||||
|
**IR** (8-bit `GREY` only, the face-unlock sensor). The AMD IPU `[1022:1502]`
|
||||||
|
is present on PCI but **unused** (the camera enumerates over USB, not the
|
||||||
|
MIPI/ISP path), so the libcamera-software-ISP branch is moot on this machine.
|
||||||
|
- The **raw color capture is fine**: at factory defaults with auto-exposure,
|
||||||
|
`/dev/video0` measures luma ≈130/255 *from the first frame* (no AE ramp, not
|
||||||
|
dark); forcing manual exposure made it *worse*. So there is **no v4l2 control
|
||||||
|
default to bake** — the speculated `v4l2-ctl`-tuning / udev-oneshot fix is the
|
||||||
|
wrong tree.
|
||||||
|
- **Real cause is the consumption path.** PipeWire/WirePlumber exposes the
|
||||||
|
device through **both** backends at once — two `[v4l2]` sources (node 144 =
|
||||||
|
`/dev/video0` Color, node 146 = `/dev/video2` **IR**) **plus** two
|
||||||
|
`[libcamera]` nodes (Color + IR) — and the IR sensor is presented as an
|
||||||
|
**indistinguishable** "Integrated Camera". So an app's camera picker shows up
|
||||||
|
to *four* identical entries, and choosing the IR one yields a black/dark
|
||||||
|
monochrome frame; the libcamera path can also negotiate the low-res `YUYV`
|
||||||
|
640×480 mode ("bad quality"). The default source is the color node, so apps
|
||||||
|
that don't let you choose are fine — the breakage is selecting (or an app
|
||||||
|
auto-selecting) the wrong node.
|
||||||
|
- **Fix — validated live on hardware (2026-06-26).** Two WirePlumber 0.5
|
||||||
|
drop-ins collapse the four entries to one clean color camera, confirmed via
|
||||||
|
`wpctl status` (before: 2 v4l2 + 2 libcamera incl. both IR nodes → after:
|
||||||
|
**1** v4l2 source = `/dev/video0` color, **0** libcamera):
|
||||||
|
1. **Hide the IR node** — `monitor.v4l2.rules` matching the IR sensor →
|
||||||
|
`node.disabled = true`. (Tested by card name `~.*Integrated I`; the
|
||||||
|
**shipping** match should key on the more robust, vendor-neutral heuristic
|
||||||
|
of a **`GREY`-only / no-color-format** node, since other vendors' IR cards
|
||||||
|
are named differently.)
|
||||||
|
2. **Drop the duplicate backend** — `wireplumber.profiles.main.monitor.libcamera
|
||||||
|
= disabled`, since a plain UVC cam is fully covered by v4l2 (also kills the
|
||||||
|
libcamera low-res-`YUYV` path).
|
||||||
|
**Drawbacks / design constraints for the module:**
|
||||||
|
- Rule 2 (libcamera off) is only safe **when a UVC camera exists** — on a
|
||||||
|
MIPI/IPU-only machine (no UVC fallback) it would kill the camera entirely,
|
||||||
|
so it **must be conditional on detection**, not blanket. Rule 1 (IR-hide)
|
||||||
|
is broadly safe. Exactly the `nomarchy.hardware.*`-gated targeting the
|
||||||
|
parent item calls for.
|
||||||
|
- **Face-unlock is *not* broken:** `node.disabled` only hides the PipeWire
|
||||||
|
node; the kernel `/dev/video2` stays openable, so Howdy (which reads the IR
|
||||||
|
device directly, bypassing PipeWire) still works.
|
||||||
|
A true MIPI/IPU software-ISP camera with no UVC fallback stays a separate
|
||||||
|
future item.
|
||||||
|
- **Shipped (2026-06-27):** `nomarchy.hardware.camera.hideIrSensor` (+ an
|
||||||
|
overridable `irMatch` regex) in `modules/nixos/hardware.nix` emits rule 1 via
|
||||||
|
`services.pipewire.wireplumber.extraConfig`; the installer's `hardware-db.sh`
|
||||||
|
auto-detects a paired RGB+IR webcam (from `/sys/class/video4linux/*/name`)
|
||||||
|
and bakes the toggle into `system.nix`, with a commented example in the
|
||||||
|
downstream template. **Decision: v4l2 IR-hide only — libcamera is left
|
||||||
|
untouched** so an external camera you plug in is never affected. Surgical
|
||||||
|
internal-only libcamera scoping proved impossible: the distinguishing device
|
||||||
|
props (`api.libcamera.location`, `device.product.name`) bind *after* the
|
||||||
|
monitor rule runs, and the only early-matchable prop (`device.api`) is
|
||||||
|
all-or-nothing — so a broad libcamera-off was the only option and was rejected
|
||||||
|
as the blunt instrument it is (external USB cams are UVC and keep working via
|
||||||
|
v4l2 regardless). Verified: installer detection fires on the T14s; the
|
||||||
|
generated drop-in's serialized content is valid WP-0.5 config; and the exact
|
||||||
|
shipped `irMatch` was re-confirmed live (1 V4L2 source = the colour
|
||||||
|
`/dev/video0`, libcamera untouched, IR node disabled in the WirePlumber log).
|
||||||
|
Remaining: an on-Nomarchy end-to-end check; optional `v4l-utils` +
|
||||||
|
`cameractrls` for the rare genuine-tuning case; and a follow-up for
|
||||||
|
portal/Flatpak apps that consume the libcamera path (where the internal IR is
|
||||||
|
still listed, since libcamera stays on).
|
||||||
- **Opt-in services & integrations:** the counterpart to the opt-*out*
|
- **Opt-in services & integrations:** the counterpart to the opt-*out*
|
||||||
application suite above — heavier or more personal integrations shipped
|
application suite above — heavier or more personal integrations shipped
|
||||||
**off by default**, each a `nomarchy.services.<name>.enable` toggle a
|
**off by default**, each a `nomarchy.services.<name>.enable` toggle a
|
||||||
|
|||||||
@@ -109,6 +109,33 @@ in
|
|||||||
distro default — for very new hardware whose drivers (a fresh NPU, the
|
distro default — for very new hardware whose drivers (a fresh NPU, the
|
||||||
`xe` GPU driver, new-platform enablement) only landed recently. Off by
|
`xe` GPU driver, new-platform enablement) only landed recently. Off by
|
||||||
default; the default kernel already carries amd-pstate and amdxdna (6.14+)'';
|
default; the default kernel already carries amd-pstate and amdxdna (6.14+)'';
|
||||||
|
|
||||||
|
camera = {
|
||||||
|
hideIrSensor = lib.mkEnableOption ''
|
||||||
|
hiding a dual-sensor webcam's IR (face-unlock) node from PipeWire so
|
||||||
|
apps only ever see the colour camera. Such modules (common on recent
|
||||||
|
ThinkPads) expose the IR sensor as a SECOND, identically-named
|
||||||
|
"Integrated Camera"; selecting it gives a dark, 8-bit-greyscale image —
|
||||||
|
the classic "my webcam is dark" symptom. The installer turns this on
|
||||||
|
when it detects a paired RGB+IR webcam. Only the PipeWire node is
|
||||||
|
disabled — the kernel /dev/video* device stays open, so face-unlock
|
||||||
|
(Howdy) still works. Acts on the V4L2 path only; the libcamera monitor
|
||||||
|
is left untouched, so an external camera you plug in is never affected.
|
||||||
|
See irMatch'';
|
||||||
|
|
||||||
|
irMatch = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "~.*(Integrated I|IR Camera|Infrared).*";
|
||||||
|
description = ''
|
||||||
|
WirePlumber regex (matched against a V4L2 node's api.v4l2.cap.card)
|
||||||
|
selecting the IR sensor to hide. The default catches the common
|
||||||
|
dual-sensor naming ("… Integrated I", "IR Camera", "Infrared"); set it
|
||||||
|
to your camera's IR card name if it differs — find it with
|
||||||
|
`v4l2-ctl --list-devices` or `wpctl inspect`. Only consulted when
|
||||||
|
camera.hideIrSensor is on.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
@@ -174,6 +201,26 @@ in
|
|||||||
nomarchy.hardware.latestKernel = true.'';
|
nomarchy.hardware.latestKernel = true.'';
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ── Webcam: hide a dual-sensor module's IR node ────────────────────
|
||||||
|
# A built-in RGB+IR webcam exposes its IR (face-unlock) sensor as a second,
|
||||||
|
# identically-named camera; an app that picks it gets a dark greyscale
|
||||||
|
# image. Disable that node on the V4L2 PipeWire path so only the colour
|
||||||
|
# camera is offered. Matched by card name (irMatch). libcamera is left
|
||||||
|
# alone on purpose — an external camera may rely on it, and surgical
|
||||||
|
# internal-only libcamera scoping isn't possible (the distinguishing
|
||||||
|
# device props bind after the monitor rule runs). The kernel /dev/video*
|
||||||
|
# stays open, so Howdy face-unlock still reads the IR sensor directly.
|
||||||
|
(lib.mkIf (cfg.camera.hideIrSensor && config.services.pipewire.wireplumber.enable) {
|
||||||
|
services.pipewire.wireplumber.extraConfig."90-nomarchy-hide-ir-camera" = {
|
||||||
|
"monitor.v4l2.rules" = [
|
||||||
|
{
|
||||||
|
matches = [ { "api.v4l2.cap.card" = cfg.camera.irMatch; } ];
|
||||||
|
actions."update-props"."node.disabled" = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
# ── Sanity ─────────────────────────────────────────────────────────
|
# ── Sanity ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
assertions = [
|
assertions = [
|
||||||
|
|||||||
@@ -184,6 +184,31 @@ nomarchy_detect_hw() {
|
|||||||
echo "DETAIL fingerprint reader detected → fprintd (PAM login/sudo opt-in)"
|
echo "DETAIL fingerprint reader detected → fprintd (PAM login/sudo opt-in)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Dual-sensor webcam (RGB + IR face-unlock). Such modules expose the IR
|
||||||
|
# sensor as a SECOND, identically-named "Integrated Camera"; an app that
|
||||||
|
# picks it shows a dark, 8-bit-greyscale image. When an IR-companion node
|
||||||
|
# sits alongside a normal camera node, enable hiding the IR one from
|
||||||
|
# PipeWire (the colour camera stays; the kernel /dev/video* is untouched, so
|
||||||
|
# Howdy can still read the IR sensor directly). Read from /sys — no
|
||||||
|
# v4l2-ctl needed in the installer env.
|
||||||
|
local cam_name have_ir_cam=0 have_color_cam=0 vf
|
||||||
|
local ir_re='Integrated I|IR Camera|Infrared'
|
||||||
|
for vf in /sys/class/video4linux/video*/name; do
|
||||||
|
[[ -e "$vf" ]] || continue
|
||||||
|
cam_name=$(cat "$vf" 2>/dev/null)
|
||||||
|
shopt -s nocasematch
|
||||||
|
if [[ "$cam_name" =~ $ir_re ]]; then
|
||||||
|
have_ir_cam=1
|
||||||
|
elif [[ "$cam_name" =~ (Camera|Webcam) ]]; then
|
||||||
|
have_color_cam=1
|
||||||
|
fi
|
||||||
|
shopt -u nocasematch
|
||||||
|
done
|
||||||
|
if [[ $have_ir_cam -eq 1 && $have_color_cam -eq 1 ]]; then
|
||||||
|
echo "NOMARCHY hardware.camera.hideIrSensor=true"
|
||||||
|
echo "DETAIL dual-sensor webcam (RGB+IR) → hide the IR node so apps get the colour camera"
|
||||||
|
fi
|
||||||
|
|
||||||
# NPU (detect-only → a commented, experimental opt-in). Match the PCI
|
# NPU (detect-only → a commented, experimental opt-in). Match the PCI
|
||||||
# "Processing accelerators" class [1200] (or accelerator keywords) and
|
# "Processing accelerators" class [1200] (or accelerator keywords) and
|
||||||
# attribute by vendor — future-proof vs a per-device-ID list, so new gens
|
# attribute by vendor — future-proof vs a per-device-ID list, so new gens
|
||||||
|
|||||||
@@ -446,14 +446,15 @@ fi
|
|||||||
# experimental opt-ins written commented for the user to flip on.
|
# experimental opt-ins written commented for the user to flip on.
|
||||||
HARDWARE_CONFIG=""
|
HARDWARE_CONFIG=""
|
||||||
if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then
|
if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then
|
||||||
has_intel=0; has_amd=0; has_fp=0; intel_guc_off=0
|
has_intel=0; has_amd=0; has_fp=0; intel_guc_off=0; has_camera_ir=0
|
||||||
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
|
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
|
||||||
for nm in "${HW_NOMARCHY[@]}"; do
|
for nm in "${HW_NOMARCHY[@]}"; do
|
||||||
case "$nm" in
|
case "$nm" in
|
||||||
hardware.intel.enable=true) has_intel=1 ;;
|
hardware.intel.enable=true) has_intel=1 ;;
|
||||||
hardware.intel.guc=false) intel_guc_off=1 ;;
|
hardware.intel.guc=false) intel_guc_off=1 ;;
|
||||||
hardware.amd.enable=true) has_amd=1 ;;
|
hardware.amd.enable=true) has_amd=1 ;;
|
||||||
hardware.fingerprint.enable=true) has_fp=1 ;;
|
hardware.fingerprint.enable=true) has_fp=1 ;;
|
||||||
|
hardware.camera.hideIrSensor=true) has_camera_ir=1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -479,6 +480,10 @@ if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then
|
|||||||
hw_lines+="
|
hw_lines+="
|
||||||
nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)
|
nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)
|
||||||
# nomarchy.hardware.fingerprint.pam = true; # use it for login + sudo (opt-in)"
|
# nomarchy.hardware.fingerprint.pam = true; # use it for login + sudo (opt-in)"
|
||||||
|
fi
|
||||||
|
if [[ $has_camera_ir -eq 1 ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node (color cam only)"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$NPU_VENDOR" ]]; then
|
if [[ -n "$NPU_VENDOR" ]]; then
|
||||||
hw_lines+="
|
hw_lines+="
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
# fingerprint.pam = true; # use the fingerprint for login + sudo
|
# fingerprint.pam = true; # use the fingerprint for login + sudo
|
||||||
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
|
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
|
||||||
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
|
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
|
||||||
|
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam
|
||||||
|
# # (installer-set when a paired RGB+IR webcam is detected)
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
# nomarchy.services.tailscale.enable = true; # mesh VPN — connect from System › VPN
|
# nomarchy.services.tailscale.enable = true; # mesh VPN — connect from System › VPN
|
||||||
|
|||||||
Reference in New Issue
Block a user