aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_configure_framework_amd_internal_mic.py
blob: 7a45fb16b9fed38f1a4d71300503a949e77497b3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
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()