"""Test configure_framework_amd_internal_mic and its DMI gate. The Framework Laptop 13 on AMD Ryzen AI 300 (Strix Point and Krackan Point alike) has a known, still-open kernel bug in the ACP PDM digital-mic path: the DMIC card (acp-pdm-mach, hw:acppdmmach) returns a constant full-scale rail (rms 0.99999) instead of audio. With UCM on, alsa-ucm-conf links that card into the ALC285 codec card, so the only "internal mic" PipeWire offers IS the broken DMIC, and the built-in microphone is dead in every app. Found on velox 2026-10-06 after the 08-13 reinstall; Framework community threads 81866 and 75748 document it across several HX 370 owners. The workaround is a WirePlumber drop-in that turns UCM off (the ALC285 falls back to its analog-stereo profile, whose Internal Microphone pin carries real audio) and disables the standalone PDM card so nothing can pick it. The step installs the shipped copy of that drop-in, and only on this hardware: the gate reads DMI, not the hostname, because the fix belongs to the board, not to velox. The gate has to be exact in both directions. The same bug does not exist on the Intel Framework 13 (SOF stack, not ACP), and the Framework Desktop (ratio, "AMD Ryzen AI Max 300") has no internal mic at all, so a vendor-only or "AMD"-only match would drop a UCM-off file onto machines whose audio is fine and silently flatten their codec profiles. Method: sed-extract both functions from the real `archsetup`, point the step at a temp conf.d dir and a fixture DMI dir, and fake display / error_warn. The shipped drop-in is read from the real configs/ tree so the test pins the content the installer actually delivers. Run from repo root: python3 -m unittest tests.installer-steps.test_configure_framework_amd_internal_mic """ import os import re import stat import subprocess import tempfile import textwrap import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") DROPIN_SRC = os.path.join(REPO_ROOT, "configs", "wireplumber", "50-disable-ucm.conf") DROPIN_NAME = "50-disable-ucm.conf" # The broken DMIC card as velox names it, and the codec card that must stay. VELOX_PDM_CARD = "alsa_card.pci-0000_c1_00.5-platform-acp-pdm-mach" VELOX_CODEC_CARD = "alsa_card.pci-0000_c1_00.6" # DMI fixtures. The product strings are what the real machines report. VELOX = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen AI 300 Series)") RATIO = dict(sys_vendor="Framework", product_name="Desktop (AMD Ryzen AI Max 300 Series)") INTEL_13 = dict(sys_vendor="Framework", product_name="Laptop (13th Gen Intel Core)") AMD_7040_13 = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen 7040Series)") AMD_16 = dict(sys_vendor="Framework", product_name="Laptop 16 (AMD Ryzen 7040 Series)") OTHER_VENDOR = dict(sys_vendor="LENOVO", product_name="Laptop 13 (AMD Ryzen AI 300 Series)") def write_dmi(dmi_dir, fields): os.makedirs(dmi_dir, exist_ok=True) for name, value in fields.items(): with open(os.path.join(dmi_dir, name), "w") as f: f.write(value + "\n") def gate(dmi_dir): script = textwrap.dedent(f"""\ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}") if is_framework_13_ryzen_ai_300 "{dmi_dir}"; then echo "GATE=yes"; else echo "GATE=no"; fi exit 0 """) r = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10) m = re.search(r"^GATE=(yes|no)$", r.stdout, re.M) assert m, "no GATE line in output: %r / %r" % (r.stdout, r.stderr) return m.group(1) == "yes" def run(confd, dmi_dir, src_root=REPO_ROOT): # The step sends install's output to $logfile; the fakes report on stdout # so the test can see the warning and the function's own return code. script = textwrap.dedent(f"""\ logfile=/dev/null action="" username=nobody display() {{ :; }} error_warn() {{ echo "WARN: $1"; return 1; }} user_archsetup_dir="{src_root}" source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}") source <(sed -n '/^configure_framework_amd_internal_mic() {{/,/^}}/p' "{ARCHSETUP}") configure_framework_amd_internal_mic "{confd}" "{dmi_dir}" echo "RC=$?" exit 0 """) return subprocess.run( ["bash", "-c", script], capture_output=True, text=True, timeout=10, ) def rc_of(r): m = re.search(r"^RC=(\d+)$", r.stdout, re.M) assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) return int(m.group(1)) class Framework13RyzenAi300Gate(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.dmi = os.path.join(self.tmp.name, "dmi") def tearDown(self): self.tmp.cleanup() # ------------------------------------------------------------ normal ---- def test_matches_the_framework_13_on_ryzen_ai_300(self): write_dmi(self.dmi, VELOX) self.assertTrue(gate(self.dmi)) # ---------------------------------------------------------- boundary ---- def test_rejects_the_framework_desktop_on_ryzen_ai_max(self): """ratio: same vendor, "AMD Ryzen AI" in the name, no internal mic. "AMD Ryzen AI Max 300" must not satisfy an "AMD Ryzen AI 300" match. """ write_dmi(self.dmi, RATIO) self.assertFalse(gate(self.dmi)) def test_rejects_the_framework_13_on_intel(self): """Intel's SOF stack has no ACP PDM path; the bug does not exist there.""" write_dmi(self.dmi, INTEL_13) self.assertFalse(gate(self.dmi)) def test_rejects_the_framework_13_on_ryzen_7040(self): """Phoenix is not Ryzen AI 300; the workaround is unverified there.""" write_dmi(self.dmi, AMD_7040_13) self.assertFalse(gate(self.dmi)) def test_rejects_the_framework_16(self): write_dmi(self.dmi, AMD_16) self.assertFalse(gate(self.dmi)) def test_rejects_another_vendor_with_the_same_model_string(self): write_dmi(self.dmi, OTHER_VENDOR) self.assertFalse(gate(self.dmi)) def test_rejects_a_missing_dmi_directory(self): self.assertFalse(gate(os.path.join(self.tmp.name, "nope"))) class ConfigureFrameworkAmdInternalMic(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.dmi = os.path.join(self.tmp.name, "dmi") self.confd = os.path.join(self.tmp.name, "wireplumber.conf.d") self.dropin = os.path.join(self.confd, DROPIN_NAME) def tearDown(self): self.tmp.cleanup() # ------------------------------------------------------------ normal ---- def test_framework_13_ryzen_ai_300_gets_the_shipped_dropin(self): write_dmi(self.dmi, VELOX) r = run(self.confd, self.dmi) self.assertEqual(rc_of(r), 0, r.stdout) self.assertTrue(os.path.exists(self.dropin), "drop-in not installed") with open(self.dropin) as f, open(DROPIN_SRC) as src: self.assertEqual(f.read(), src.read(), "the installed file must be the shipped file, byte for byte") def test_dropin_mode_is_0644(self): """Owner-writable, world-readable, like every file under /etc.""" write_dmi(self.dmi, VELOX) run(self.confd, self.dmi) mode = stat.S_IMODE(os.stat(self.dropin).st_mode) self.assertEqual(mode, 0o644) def test_shipped_dropin_turns_ucm_off(self): """Pin the shipped content, not just the copy: use-ucm must be an active setting, not a commented example.""" with open(DROPIN_SRC) as f: body = f.read() active = [ln for ln in body.splitlines() if "alsa.use-ucm" in ln and not ln.lstrip().startswith("#")] self.assertEqual(len(active), 1, body) self.assertRegex(active[0], r"alsa\.use-ucm\s*=\s*false") def test_shipped_dropin_disables_the_standalone_pdm_card(self): """Without UCM the broken DMIC still shows up as its own card; the rule has to catch it and disable it, or an app or the default selection can still land on the full-scale rail. The gate fires on every Framework 13 on Ryzen AI 300, so the rule must match the card by name pattern (WirePlumber's ~regex form), not by the one PCI path velox happens to enumerate: a renumbered bus on the same board would otherwise get UCM turned off with the broken card left selectable. WirePlumber's regex match is unanchored, so re.search mirrors it. """ with open(DROPIN_SRC) as f: body = f.read() m = re.search(r'device\.name\s*=\s*"~([^"]+)"', body) self.assertIsNotNone(m, "the rule must match device.name with a ~regex, " "not a literal PCI path") assert m is not None # narrows the type; the assertion above is the test pattern = m.group(1) self.assertTrue(re.search(pattern, VELOX_PDM_CARD), f"{pattern!r} must match velox's card {VELOX_PDM_CARD!r}") self.assertTrue(re.search(pattern, "alsa_card.pci-0000_c3_00.5-platform-acp-pdm-mach"), "the same card on another bus number must match too") self.assertFalse(re.search(pattern, VELOX_CODEC_CARD), "the ALC285 codec card carries the working mic and must not be disabled") self.assertRegex(body, r"device\.disabled\s*=\s*true") # ---------------------------------------------------------- boundary ---- def test_other_hardware_writes_nothing(self): for fixture in (RATIO, INTEL_13, AMD_7040_13, AMD_16, OTHER_VENDOR): with self.subTest(product=fixture["product_name"], vendor=fixture["sys_vendor"]): write_dmi(self.dmi, fixture) r = run(self.confd, self.dmi) self.assertEqual(rc_of(r), 0, r.stdout) self.assertFalse(os.path.exists(self.confd), "no conf.d may be created on hardware the step does not apply to") self.assertNotIn("WARN:", r.stdout) def test_missing_dmi_directory_is_a_quiet_no_op(self): """A VM or a stripped sysfs reports no DMI; that is not an error.""" r = run(self.confd, os.path.join(self.tmp.name, "nope")) self.assertEqual(rc_of(r), 0, r.stdout) self.assertFalse(os.path.exists(self.confd)) self.assertNotIn("WARN:", r.stdout) def test_running_twice_leaves_one_identical_dropin(self): write_dmi(self.dmi, VELOX) run(self.confd, self.dmi) with open(self.dropin) as f: first = f.read() r = run(self.confd, self.dmi) self.assertEqual(rc_of(r), 0, r.stdout) with open(self.dropin) as f: second = f.read() self.assertEqual(first, second) self.assertEqual(os.listdir(self.confd), [DROPIN_NAME]) def test_absent_confd_is_created(self): """wireplumber's package ships /etc/wireplumber/ but not conf.d/; the step must create the nested path rather than fail on it.""" write_dmi(self.dmi, VELOX) nested = os.path.join(self.tmp.name, "etc", "wireplumber", "wireplumber.conf.d") r = run(nested, self.dmi) self.assertEqual(rc_of(r), 0, r.stdout) self.assertTrue(os.path.exists(os.path.join(nested, DROPIN_NAME))) # ------------------------------------------------------------- error ---- def test_missing_shipped_file_warns_and_does_not_crash(self): """A partial checkout has no configs/ tree; surface it, keep going.""" write_dmi(self.dmi, VELOX) empty_root = os.path.join(self.tmp.name, "empty-checkout") os.makedirs(empty_root) r = run(self.confd, self.dmi, src_root=empty_root) self.assertIn("WARN:", r.stdout) self.assertNotEqual(rc_of(r), 0) self.assertFalse(os.path.exists(self.dropin)) @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") def test_unwritable_confd_warns_and_does_not_crash(self): write_dmi(self.dmi, VELOX) os.makedirs(self.confd) os.chmod(self.confd, 0o500) try: r = run(self.confd, self.dmi) self.assertIn("WARN:", r.stdout) self.assertNotEqual(rc_of(r), 0) finally: os.chmod(self.confd, 0o700) if __name__ == "__main__": unittest.main()