diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d786dc..3cb4d3d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -428,6 +428,74 @@ how to override it. Items marked ✓ are shipped. distro-wide regardless). Sub-items here can graduate into their own roadmap entries as they're 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* application suite above — heavier or more personal integrations shipped **off by default**, each a `nomarchy.services..enable` toggle a diff --git a/modules/nixos/hardware.nix b/modules/nixos/hardware.nix index b3d2c14..1d6dad4 100644 --- a/modules/nixos/hardware.nix +++ b/modules/nixos/hardware.nix @@ -109,6 +109,33 @@ in distro default — for very new hardware whose drivers (a fresh NPU, the `xe` GPU driver, new-platform enablement) only landed recently. Off by 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 [ @@ -174,6 +201,26 @@ in 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 ───────────────────────────────────────────────────────── { assertions = [ diff --git a/pkgs/nomarchy-install/hardware-db.sh b/pkgs/nomarchy-install/hardware-db.sh index c1cec3b..a09fa85 100644 --- a/pkgs/nomarchy-install/hardware-db.sh +++ b/pkgs/nomarchy-install/hardware-db.sh @@ -184,6 +184,31 @@ nomarchy_detect_hw() { echo "DETAIL fingerprint reader detected → fprintd (PAM login/sudo opt-in)" 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 # "Processing accelerators" class [1200] (or accelerator keywords) and # attribute by vendor — future-proof vs a per-device-ID list, so new gens diff --git a/pkgs/nomarchy-install/nomarchy-install.sh b/pkgs/nomarchy-install/nomarchy-install.sh index 83f96de..f40f1b5 100644 --- a/pkgs/nomarchy-install/nomarchy-install.sh +++ b/pkgs/nomarchy-install/nomarchy-install.sh @@ -446,14 +446,15 @@ fi # experimental opt-ins written commented for the user to flip on. HARDWARE_CONFIG="" 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 for nm in "${HW_NOMARCHY[@]}"; do case "$nm" in - hardware.intel.enable=true) has_intel=1 ;; - hardware.intel.guc=false) intel_guc_off=1 ;; - hardware.amd.enable=true) has_amd=1 ;; - hardware.fingerprint.enable=true) has_fp=1 ;; + hardware.intel.enable=true) has_intel=1 ;; + hardware.intel.guc=false) intel_guc_off=1 ;; + hardware.amd.enable=true) has_amd=1 ;; + hardware.fingerprint.enable=true) has_fp=1 ;; + hardware.camera.hideIrSensor=true) has_camera_ir=1 ;; esac done fi @@ -479,6 +480,10 @@ if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then hw_lines+=" nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll) # 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 if [[ -n "$NPU_VENDOR" ]]; then hw_lines+=" diff --git a/templates/downstream/system.nix b/templates/downstream/system.nix index 4cdb0b4..404e49f 100644 --- a/templates/downstream/system.nix +++ b/templates/downstream/system.nix @@ -46,6 +46,8 @@ # fingerprint.pam = true; # use the fingerprint for login + sudo # 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) + # 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