aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/installer-steps/test_configure_framework_amd_internal_mic.py277
1 files changed, 277 insertions, 0 deletions
diff --git a/tests/installer-steps/test_configure_framework_amd_internal_mic.py b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
new file mode 100644
index 0000000..7a45fb1
--- /dev/null
+++ b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
@@ -0,0 +1,277 @@
+"""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()