From 6e67c74fe6feb5efb402eb44956cff8ccd9a1bcd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 19:20:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(audiod):=20M50=20r3=20=E8=AF=84=E5=AE=A1=20?= =?UTF-8?q?P2=20=E4=B8=89=E6=9D=A1=20=E2=80=94=20fd=20=E5=8D=AB=E7=94=9F?= =?UTF-8?q?=20/=20=E7=AB=AF=E7=82=B9=E8=B7=AF=E5=BE=84=E6=8C=89=20MAC=20?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E5=8C=B9=E9=85=8D=20/=20=E5=A4=9A=20ACL=20?= =?UTF-8?q?=E9=80=90=E4=B8=AA=E8=AF=95=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审 r1(p2-3items/merge,reviewed 26c6077)三条: ① P2-1 dev_close() 在「BT 写路径判死 → 回落」这条路上会 close(0) 误关 stdin: 播放线程处理 g_bt_fail_req 时**提前把 g_bt_dev 清 0**,下拍 dev_close() 就落到 close(g_dev_fd) 分支,而 BT 打开成功时 dev_open() 返回 0(g_dev_fd==0)。 - 去掉那行提前清零(让 dev_close 走 BT 分支自己清),并同时置 g_dev_fd = -1 做第二道保险; - 把关闭决策抽成纯函数 bt_dev_close_kind(bt_dev, fd)(0=NONE/1=BT/2=FD):**fd 0/1/2 一律不当设备 fd 关**,宿主单测 D10 覆盖(含 bt_dev=0+fd=0 这条回归用例); - 补不变式「设备 fd 恒 >2」:dev_open 的 DAC/WAV open 过 fd_high()(0..2 时 F_DUPFD 升位, dup 失败则关掉并报打开失败——绝不留 stdio 区间里没人认领的设备 fd); - dev_close 注释改成诚实版:不再声称「已消除」,写明「BT 分支内自行清理;调用方不得 提前清 g_bt_dev」。 ② P2-2 g_bt_pcm 只写一次 → 换耳机 A→B 后 volume/info 打在 A 的端点上: - bt_refresh_endpoint() 每次先清空再探测,并用新 bt_dev_pick_endpoint() **按 g_bt_mac 精确挑端点**(找不到就留空,宁可跳过 volume/info 也不串台); - bt_vol_full() 删除「拿 list-pcms 第一条端点凑数」的兜底(同样会串台); - 单测 D9:两条端点里按 MAC 分别取 A/B、未知 MAC 返回 -1。 ③ P2-3 bt_dev_acl_mac() 只取第一条 ACL,多设备(手机/手柄并存,M49 正在测 BT 控制面) 会挑错设备 → 耳机连着却回落 DAC: - 改名/重写为 bt_dev_acl_macs():收集**全部** ACL MAC(去重、上限由调用方给); - bt_find_mac() → bt_collect_candidates():①list-pcms 里已有端点的设备优先(真 A2DP) ②其后全部 ACL 设备;候选按可信度排序、去重; - bt_try_open() 改为对候选**轮流试开**,3 次尝试、300ms 间隔(1 候选=A,A,A 与 r2 语义 相同;2 候选=A,B,A;3 候选=A,B,C),仍受退避门控;**删除裸 "bluealsa" 兜底** (那会落到「最近连接」的错设备,review §P2-3 明确指出); - 单测 D8c/D8d:两条 ACL 全取到且保序、三条含重复时去重并受上限裁剪。 顺带清死代码(评审「其余观察 2」):删除 bt_dev_count_src()、bt_dev_endpoint_path() (生产路径已无调用者,前者早已只剩单测)。 宿主:test_bt_dev 71/71(新增 D8c/D8d/D9/D10 共 10 条),test_audiod_host 109/0, 交叉编译 0 warning(68604B,md5 9f58b2197ed91d24951918d41a78cf29,NEEDED 三条、 interpreter /lib/ld.so.1、GLIBC≤2.17),test_time32_abi.sh --builds 里 `build meow_audio src=yes macro=yes` 保持。真机项(耳机)未动:本轮不连蓝牙、不扫设备。 --- patch_v2/meow_audiod/bt_dev.c | 97 +++++++++++--------- patch_v2/meow_audiod/bt_dev.h | 22 +++-- patch_v2/meow_audiod/meow_audiod.c | 142 ++++++++++++++++++----------- patch_v2/meow_audiod/test_bt_dev.c | 129 ++++++++++++++------------ 4 files changed, 222 insertions(+), 168 deletions(-) diff --git a/patch_v2/meow_audiod/bt_dev.c b/patch_v2/meow_audiod/bt_dev.c index 24eba6e..b63b943 100644 --- a/patch_v2/meow_audiod/bt_dev.c +++ b/patch_v2/meow_audiod/bt_dev.c @@ -26,17 +26,21 @@ static int line_is_src(const char *ln) return strstr(p, "a2dpsrc") != NULL || strstr(p, "a2dp_source") != NULL; } -/* 从 `hcitool con` 输出取第一条 ACL 连接的对端 BD_ADDR。 +/* 从 `hcitool con` 输出收集**所有** ACL 连接的对端 BD_ADDR(按出现顺序去重)。 * 内核 ACL 是「设备是否真的连着」的权威(M45/M49 结论);bluealsa 的 list-pcms 只在 * profile 配置好后的**秒级窗口**里存在,不能当 MAC 的唯一来源(M50 真机实测)。 + * ⚠️ 多条 ACL 是常态(手机/手柄/第二个耳机同时连着)——**不能只取第一条**: + * 取错设备会 snd_pcm_open 失败 → 有耳机却回落 DAC(M50 r1 评审 P2-3)。 * 行形如:`\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER AUTH ENCRYPT` - * 返回 0=找到(17 字符大写冒号分隔),-1=无 ACL 连接/参数非法。 */ -int bt_dev_acl_mac(const char *hcitool_con, char *mac, size_t macsz) + * 返回找到的条数(0=无 ACL 连接)。macs 是 [max][18] 的二维数组,元素写成 NUL 结尾 + * 17 字符大写冒号 MAC。 */ +int bt_dev_acl_macs(const char *hcitool_con, char (*macs)[18], int max) { - if (!hcitool_con || !mac || macsz < 18) return -1; - mac[0] = 0; + if (!hcitool_con || !macs || max <= 0) return 0; + for (int i = 0; i < max; i++) macs[i][0] = 0; + int n = 0; const char *ln = hcitool_con; - while (ln && *ln) { + while (ln && *ln && n < max) { const char *nl = strchr(ln, '\n'); const char *le = nl ? nl : (ln + strlen(ln)); size_t linelen = (size_t)(le - ln); @@ -44,8 +48,7 @@ int bt_dev_acl_mac(const char *hcitool_con, char *mac, size_t macsz) if (linelen >= sizeof line) linelen = sizeof line - 1; memcpy(line, ln, linelen); line[linelen] = 0; if (strstr(line, "ACL")) { - /* 行内找合法的 MAC token(6 组 2 位十六进制,冒号分隔) */ - for (const char *p = line; *p; p++) { + for (const char *p = line; *p && n < max; p++) { int ok = 1; for (int i = 0; i < 17 && ok; i++) { if ((i % 3) == 2) { if (p[i] != ':') ok = 0; } @@ -54,24 +57,27 @@ int bt_dev_acl_mac(const char *hcitool_con, char *mac, size_t macsz) if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) ok = 0; } } - /* 必须是完整 token:前一个字符不是十六进制/冒号,后一个不是 */ - if (ok && (p == line || !(p[-1] == ':' || (p[-1] >= '0' && p[-1] <= '9') || - (p[-1] >= 'A' && p[-1] <= 'F') || (p[-1] >= 'a' && p[-1] <= 'f'))) - && !(p[17] == ':' || (p[17] >= '0' && p[17] <= '9') || - (p[17] >= 'A' && p[17] <= 'F') || (p[17] >= 'a' && p[17] <= 'f'))) { - for (int i = 0; i < 17; i++) { - char c = p[i]; - mac[i] = (c >= 'a' && c <= 'f') ? (char)(c - 'a' + 'A') : c; - } - mac[17] = 0; - return 0; + if (!ok || + !(p == line || !(p[-1] == ':' || (p[-1] >= '0' && p[-1] <= '9') || + (p[-1] >= 'A' && p[-1] <= 'F') || (p[-1] >= 'a' && p[-1] <= 'f'))) || + (p[17] == ':' || (p[17] >= '0' && p[17] <= '9') || + (p[17] >= 'A' && p[17] <= 'F') || (p[17] >= 'a' && p[17] <= 'f'))) + continue; + char mac[18]; + for (int i = 0; i < 17; i++) { + char c = p[i]; + mac[i] = (c >= 'a' && c <= 'f') ? (char)(c - 'a' + 'A') : c; } + mac[17] = 0; + int dup = 0; /* 同一设备可能在多行/多 profile 出现 */ + for (int i = 0; i < n; i++) if (strcmp(macs[i], mac) == 0) { dup = 1; break; } + if (!dup) memcpy(macs[n++], mac, 18); } } if (!nl) break; ln = nl + 1; } - return -1; + return n; } /* 从 D-Bus 对象路径取 MAC。bluealsa 4.1.1 的 PCM 路径是 @@ -163,37 +169,27 @@ int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz) return -1; } -int bt_dev_count_src(const char *listpcms) +/* 在 list-pcms 里按「设备 MAC」精确挑端点路径(A→B 换耳机后不能拿 A 的路径去发 volume/info)。 + * 返回 0=找到(写到 out),-1=列表里没有该 MAC 的端点。 */ +int bt_dev_pick_endpoint(const char *listpcms, const char *mac, char *out, size_t outsz) { - if (!listpcms) return 0; - int n = 0; - const char *ln = listpcms; - while (ln && *ln) { - const char *nl = strchr(ln, '\n'); - if (line_is_src(ln)) n++; - if (!nl) break; - ln = nl + 1; - } - return n; -} - -/* 取第一个 a2dpsrc/a2dp_source 端点的**完整 D-Bus 路径**(`bluealsa-cli volume|info` 要的就是它, - * 不是 MAC)。行内按空白截断(含 \r,防 CRLF 把换行带进命令);越界/无端点 → -1。 */ -int bt_dev_endpoint_path(const char *listpcms, char *out, size_t outsz) -{ - if (!listpcms || !out || outsz < 2) return -1; + if (!listpcms || !mac || !mac[0] || !out || outsz < 2) return -1; out[0] = 0; const char *ln = listpcms; while (ln && *ln) { const char *nl = strchr(ln, '\n'); - const char *p = ln; - while (*p == ' ' || *p == '\t') p++; - if (line_is_src(ln)) { + const char *le = nl ? nl : (ln + strlen(ln)); + size_t linelen = (size_t)(le - ln); + char line[256], lmac[18]; + if (linelen >= sizeof line) linelen = sizeof line - 1; + memcpy(line, ln, linelen); line[linelen] = 0; + if (line_is_src(line) && parse_dev_seg(line, lmac, sizeof lmac) == 0 && + strcasecmp(lmac, mac) == 0) { + const char *p = line; + while (*p == ' ' || *p == '\t') p++; size_t n = 0; - while (p[n] && p[n] != ' ' && p[n] != '\t' && p[n] != '\n' && p[n] != '\r' && n + 1 < outsz) { - out[n] = p[n]; - n++; - } + while (p[n] && p[n] != ' ' && p[n] != '\t' && p[n] != '\n' && p[n] != '\r' && + n + 1 < outsz) { out[n] = p[n]; n++; } out[n] = 0; return n ? 0 : -1; } @@ -203,6 +199,17 @@ int bt_dev_endpoint_path(const char *listpcms, char *out, size_t outsz) return -1; } +/* 关闭决策(纯函数,宿主可单测):当前「设备」该关谁。 + * bt_dev=1 → 关 BT PCM(句柄在 audiod 的 g_bt_hdl),**不碰 fd**; + * 否则 fd>2 才当设备 fd 关;**0/1/2 一律不动**——BT 打开成功时 dev_open() 返回 0, + * 任何「提前清 bt_dev」的路径都会落到 close(g_dev_fd) 上误关 stdin(M50 r1 评审 P2-1)。 + * 返回 0=NONE / 1=BT / 2=FD。 */ +int bt_dev_close_kind(int bt_dev, int fd) +{ + if (bt_dev) return 1; + return fd > 2 ? 2 : 0; +} + /* 从 `bluealsa-cli info ` 输出取实际协商到的 codec("Selected codec: AAC" → "AAC")。 * 只认行首 "Selected codec:"("Available codecs: SBC AAC" 那行不能误匹配)。 */ int bt_dev_info_codec(const char *info_out, char *out, size_t outsz) diff --git a/patch_v2/meow_audiod/bt_dev.h b/patch_v2/meow_audiod/bt_dev.h index 15281cd..75f2f41 100644 --- a/patch_v2/meow_audiod/bt_dev.h +++ b/patch_v2/meow_audiod/bt_dev.h @@ -20,9 +20,11 @@ #include -/* 从 `hcitool con` 输出取第一条 ACL 连接的对端 BD_ADDR(**MAC 主来源**:内核 ACL 是连接权威, - * list-pcms 只在 profile 配置后的秒级窗口内存在)。返回 0=找到,-1=无 ACL/参数非法。 */ -int bt_dev_acl_mac(const char *hcitool_con, char *mac, size_t macsz); +/* 从 `hcitool con` 输出收集**所有** ACL 连接的对端 BD_ADDR(按出现顺序去重;MAC 主来源: + * 内核 ACL 是连接权威,list-pcms 只在 profile 配置后的秒级窗口内存在)。 + * 多设备是常态,调用方要**逐个试开**而不是只取第一条(M50 r1 评审 P2-3)。 + * macs 为 [max][18];返回条数(0=无 ACL)。 */ +int bt_dev_acl_macs(const char *hcitool_con, char (*macs)[18], int max); /* 解析 bluealsa-cli list-pcms 输出,取第一个 a2dpsrc/a2dp_source 端点的设备 MAC * (**兜底来源**:list-pcms 可见时用)。 @@ -32,14 +34,14 @@ int bt_dev_acl_mac(const char *hcitool_con, char *mac, size_t macsz); * macsz ≥ 18。 */ int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz); -/* 统计 list-pcms 输出里的 a2dpsrc/a2dp_source 端点数(0=无可用 A2DP 源端点)。 - * 用于「先探测端点、再决定是否打开」——无端点时省掉一次注定失败的 open。 */ -int bt_dev_count_src(const char *listpcms); -/* 取 list-pcms 里第一个 a2dpsrc/a2dp_source 端点的完整 D-Bus 路径 - * (/org/bluealsa/hci0/dev_XX_../a2dpsrc/sink)——`bluealsa-cli volume|info` 要的是它,不是 MAC。 - * 返回 0=写好(out NUL 结尾),-1=无端点/参数非法。outsz 建议 ≥96。 */ -int bt_dev_endpoint_path(const char *listpcms, char *out, size_t outsz); +/* 在 list-pcms 里按设备 MAC 精确挑端点路径(换耳机 A→B 后不能拿 A 的路径发 volume/info)。 + * 返回 0=找到,-1=该 MAC 无端点。 */ +int bt_dev_pick_endpoint(const char *listpcms, const char *mac, char *out, size_t outsz); + +/* 关闭决策(纯函数):bt_dev=1 → 关 BT PCM;否则 fd>2 才关;0/1/2 一律不动(护 stdin)。 + * 返回 0=NONE / 1=BT / 2=FD。 */ +int bt_dev_close_kind(int bt_dev, int fd); /* 从 `bluealsa-cli info` 输出取实际协商 codec:行首 "Selected codec: AAC" → "AAC"。 * 返回 0=找到,-1=无该行/参数非法。 */ diff --git a/patch_v2/meow_audiod/meow_audiod.c b/patch_v2/meow_audiod/meow_audiod.c index 6d8d388..4580075 100644 --- a/patch_v2/meow_audiod/meow_audiod.c +++ b/patch_v2/meow_audiod/meow_audiod.c @@ -82,7 +82,6 @@ * @args = DEV PROFILE CODEC VOL SOFTVOL DELAY SRV)。DEV/CODEC 直接从 PCM 名内联传入, * 一次 snd_pcm_open 即完成「钉设备 + 钉 codec」,无需 DEV 自识别改写文件。 * ⚠️ /usr/data/alsa.conf 是 HiBy 播放器的数据文件、语法非法、libasound 从不加载——不读也不写。 */ -#define BT_PCM_BARE "bluealsa" /* 兜底:仅当内联名失败时试一次配置缺省(DEV 通配) */ #define BT_CODEC_AUTO "auto" #define BT_VOL_FULL_PCM "/usr/bin/bluealsa-cli" /* 一键设 AVRCP 100%(best-effort,不存在则跳过) */ @@ -976,39 +975,52 @@ static void bt_note_ok(void) g_bt_fail_n = 0; } -/* 找「现在要往哪台设备播」的 MAC。**主来源 = 内核 ACL(hcitool con)**: - * 这是「耳机真的连着」的权威,且开销极小;bluealsa 的 list-pcms 只在 profile 配置后的 - * 秒级窗口里才列出端点(M50 真机:无人开 PCM 时 1~3s 就消失),**不能当唯一来源** - * (否则探针一错过窗口就 bt=fail 回落)。list-pcms 仅作兜底,并顺手记下端点 D-Bus 路径 - * (volume/info 要用;PCM 被撑住后它才稳定,见 bt_refresh_endpoint)。 - * 返回 1=找到 MAC(写入 mac),0=当前没有可播的蓝牙设备。 */ -static int bt_find_mac(char *mac, size_t macsz) +/* 收集「可以试着往它播」的设备 MAC 候选(按可信度排序,去重): + * ① list-pcms 里**已经存在端点**的设备 —— 那就是真的 A2DP 连接(最可信,窗口内命中时用它); + * ② `hcitool con` 的**全部** ACL 设备 —— 内核权威,覆盖「profile 还没配置好、端点还没出现」。 + * ⚠️ 多条 ACL 是常态(手机/手柄/第二副耳机);只取第一条会挑错设备 → 耳机连着却回落 DAC + * (M50 r1 评审 P2-3)。候选交给 bt_try_open 逐个试开(上限 3 次尝试)。 + * 返回候选数(0=当前没有可播的蓝牙设备)。 */ +static int bt_collect_candidates(char (*cand)[18], int max) { - if (mac && macsz) mac[0] = 0; - char *o = popen_read_tmo(1200, "hcitool con 2>/dev/null"); - if (o && bt_dev_acl_mac(o, mac, macsz) == 0) { - logmsg("bt: ACL device %s (source=hcitool con)", mac); - return 1; + if (!cand || max <= 0) return 0; + int n = 0; + char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null"); + if (o && o[0]) { + char m[18]; + if (bt_dev_detect_mac(o, m, sizeof m) == 0) { + memcpy(cand[n++], m, 18); + logmsg("bt: candidate#%d %s (source=list-pcms endpoint)", n, m); + } } - o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null"); - if (!o) { logmsg("bt: no ACL and no bluealsa-cli"); return 0; } - char path[128]; - if (bt_dev_endpoint_path(o, path, sizeof path) == 0) snprintf(g_bt_pcm, sizeof g_bt_pcm, "%s", path); - if (bt_dev_detect_mac(o, mac, macsz) == 0) { - logmsg("bt: a2dp dev %s (source=list-pcms fallback)", mac); - return 1; + o = popen_read_tmo(1200, "hcitool con 2>/dev/null"); + if (o) { + char macs[4][18]; + int k = bt_dev_acl_macs(o, macs, 4); + for (int i = 0; i < k && n < max; i++) { + int dup = 0; + for (int j = 0; j < n; j++) if (strcmp(cand[j], macs[i]) == 0) { dup = 1; break; } + if (dup) continue; + memcpy(cand[n++], macs[i], 18); + logmsg("bt: candidate#%d %s (source=hcitool con ACL)", n, macs[i]); + } } - return 0; + return n; } -/* PCM 已打开后刷新端点路径(此时 bluealsa 的端点对象是稳定的),供 info/volume 用。 */ +/* 用当前 g_bt_mac 精确刷新端点路径(PCM 已打开后 bluealsa 端点对象才稳定)。 + * **必须按 MAC 匹配**:换耳机 A→B 后若沿用 A 的路径,volume/info 会打到旧设备上 + * (M50 r1 评审 P2-2)。找不到本设备的端点就留空(宁可跳过 volume/info 也不串台)。 */ static void bt_refresh_endpoint(void) { - if (g_bt_pcm[0]) return; + g_bt_pcm[0] = 0; char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null"); if (!o) return; char path[128]; - if (bt_dev_endpoint_path(o, path, sizeof path) == 0) snprintf(g_bt_pcm, sizeof g_bt_pcm, "%s", path); + if (bt_dev_pick_endpoint(o, g_bt_mac, path, sizeof path) == 0) + snprintf(g_bt_pcm, sizeof g_bt_pcm, "%s", path); + else + logmsg("bt: endpoint for %s not listed yet (volume/info skipped)", g_bt_mac); } /* 按 PCM 名打开(libasound → NONBLOCK open + set_params S16/RW/ch/rate/soft_resample/100ms)。 @@ -1053,28 +1065,29 @@ static int bt_try_open(void) g_bt_fail_n, bt_dev_backoff_ms(g_bt_fail_n), since); return -1; } - char mac[20] = ""; - if (!bt_find_mac(mac, sizeof mac)) { bt_note_fail("no connected BT device (hcitool con empty)"); return -1; } - snprintf(g_bt_mac, sizeof g_bt_mac, "%s", mac); + char cand[3][18]; + int ncand = bt_collect_candidates(cand, 3); + if (ncand <= 0) { bt_note_fail("no BT device (no a2dp endpoint, no ACL)"); return -1; } bt_dev_codec_token(g_bt_codec, g_bt_tok, sizeof g_bt_tok); - char name[96]; - if (bt_dev_pcm_name(g_bt_mac, g_bt_tok, name, sizeof name) != 0) { bt_note_fail("bad device MAC"); return -1; } - logmsg("bt: try open %s (codec setting=%s -> token=%s)", name, g_bt_codec, g_bt_tok); - /* 3 次 × 300ms 短重试:ACL 刚建立时 BlueZ 可能还没把 A2DP profile 配置完, - * 这一下会返回 ENODEV/EBUSY;等 300ms 再来通常就通了。3 次仍失败才记账进退避 - * (短重试上限 600ms,不会在播放线程里形成长时间阻塞)。 */ + /* 3 次尝试、每次间隔 300ms(ACL 刚建立时 BlueZ 可能还没配置完 A2DP profile,等一拍就通)。 + * 多候选时轮流试:1 个候选→A,A,A(=r2 语义);2 个→A,B,A;3 个→A,B,C。 + * 候选都来自「真的连着」的集合,每次尝试都有意义;总耗时上限 ≈600ms + open 时延。 + * **不再用裸 "bluealsa" 兜底**:那会落到「最近连接」的设备上,多设备时等于挑错人。 */ const char *opened = NULL; for (int i = 0; i < 3 && !opened; i++) { - if (i) { - struct timespec ts = { 0, 300 * 1000000L }; - nanosleep(&ts, NULL); + const char *mac = cand[i % ncand]; + if (i) { struct timespec ts = { 0, 300 * 1000000L }; nanosleep(&ts, NULL); } + char name[96]; + if (bt_dev_pcm_name(mac, g_bt_tok, name, sizeof name) != 0) continue; + logmsg("bt: try#%d open %s (codec setting=%s -> token=%s)", i + 1, name, g_bt_codec, g_bt_tok); + if (bt_open_pcm(name) == 0) { + opened = name; + snprintf(g_bt_mac, sizeof g_bt_mac, "%s", mac); } - if (bt_open_pcm(name) == 0) opened = name; - else if (bt_open_pcm(BT_PCM_BARE) == 0) opened = BT_PCM_BARE; } if (!opened) { bt_note_fail("snd_pcm_open/set_params failed (3 tries)"); return -1; } bt_note_ok(); - bt_refresh_endpoint(); /* PCM 已撑住:端点对象稳定,取路径给 info/volume */ + bt_refresh_endpoint(); /* 端点对象此时稳定;按 MAC 精确取路径(换设备不串台) */ bt_read_used_codec(); g_bt_vol_full_done = 0; /* 新设备/PCM:允许再促成一次 AVRCP 满量程 */ bt_vol_full(); @@ -1158,15 +1171,11 @@ static void bt_vol_full(void) { if (g_bt_vol_full_done) return; g_bt_vol_full_done = 1; + /* 只用 bt_refresh_endpoint() 按 MAC 精确取到的路径;为空就**跳过**—— + * 宁可这一次不顶音量,也不拿别的设备的端点发 volume(换耳机 A→B 串台,评审 P2-2)。 */ char pcm[128]; - snprintf(pcm, sizeof pcm, "%s", g_bt_pcm); /* bt_probe 已取到端点路径(省一次 popen) */ - if (!pcm[0]) { - char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null"); - if (!o) { logmsg("bt: avrcp set-max skipped (no bluealsa-cli)"); return; } - if (bt_dev_endpoint_path(o, pcm, sizeof pcm) != 0) { - logmsg("bt: avrcp set-max skipped (no a2dpsrc pcm found)"); return; - } - } + snprintf(pcm, sizeof pcm, "%s", g_bt_pcm); + if (!pcm[0]) { logmsg("bt: avrcp set-max skipped (no endpoint path for %s)", g_bt_mac); return; } char cmd[256]; snprintf(cmd, sizeof cmd, "%s volume %s 127 >/dev/null 2>&1", BT_VOL_FULL_PCM, pcm); char *r = popen_read_tmo(1500, cmd); @@ -1392,6 +1401,24 @@ static void dac_fallback_arm(void) apply_route(port); } +/* 把设备 fd 升到 ≥3:BT 打开成功时 dev_open() 返回 0,而 0/1/2 是 stdio——把设备 fd + * 钉在 0..2 上等于给「谁误清了 g_bt_dev」埋地雷(dev_close 的 close_kind 守卫会跳过 0..2)。 + * open() 只在 stdio 被关掉时才可能返回 <3;这里 dup 一次让「设备 fd 恒 >2」成为不变式。 + * dup 也失败(极端 EMFILE)时把原 fd 关掉并报失败——绝不留一个「在 stdio 区间又没人认领」 + * 的设备 fd(那会变成打开的设备泄漏,比关一次 stdin 更糟)。 */ +static int fd_high(int fd) +{ + if (fd < 0 || fd > 2) return fd; + int nfd = fcntl(fd, F_DUPFD, 3); + if (nfd < 0) { + logmsg("fd %d in stdio range and dup failed: %s -> treat as open failure", fd, strerror(errno)); + close(fd); + return -1; + } + close(fd); + return nfd; +} + static int dev_open(void) { /* 输出目标=BT 且走真机 ALSA:打开 libasound 的 bluealsa PCM(内联 DEV/CODEC,不开 hw:0,0)。 @@ -1411,7 +1438,7 @@ static int dev_open(void) #ifdef AUDIOD_HAVE_ALSA /* O_NONBLOCK:写路径走 poll(POLLOUT)+限时(dev_write),write 本身永不睡眠—— * 驱动 wedge 时绝不能把播放线程永久压进内核(真机长流卡死根因防线)。 */ - int fd = open(DEV_PCM, O_RDWR | O_NONBLOCK); + int fd = fd_high(open(DEV_PCM, O_RDWR | O_NONBLOCK)); if (fd < 0) { logmsg("dev open %s: %s", DEV_PCM, strerror(errno)); return -1; } if (alsa_hw_params(fd, rate, ch, alsa_fmt_to_snd((int)fmt)) != 0) { logmsg("dev hw_params %u/%u/%s failed", (unsigned)rate, (unsigned)ch, fmt_name((int)fmt)); @@ -1425,7 +1452,7 @@ static int dev_open(void) #endif } if (g_sink == SINK_FILE) { - int fd = open(g_out_wav, O_WRONLY | O_CREAT | O_TRUNC, 0644); + int fd = fd_high(open(g_out_wav, O_WRONLY | O_CREAT | O_TRUNC, 0644)); if (fd < 0) { logmsg("dev open wav %s: %s", g_out_wav, strerror(errno)); return -1; } wav_header(fd, (int)rate, (int)ch, bits, 0); return fd; @@ -1519,9 +1546,15 @@ static void dev_write(uint8_t *buf, size_t n) static void dev_close(void) { - /* 分流用 g_bt_dev(而非 g_bt_open):写路径判死时 bt_write 已 bt_close()(g_bt_open=0), - * 若此时按 g_bt_open 走 DAC 分支会把 g_dev_fd(=0) 当成真 fd 关掉(误关 stdin)。 */ - if (g_bt_dev) { bt_close(); g_bt_dev = 0; g_dev_fd = -1; g_dev_open = 0; return; } + /* 关设备的决策交给 bt_dev_close_kind()(纯函数,宿主可单测): + * - **BT 分支内自行清理**:认的是 g_bt_dev(不是 g_bt_open——写路径判死时 bt_write 已 + * bt_close(),g_bt_open 已是 0),进来后把 g_bt_dev/g_dev_fd 一起复位; + * - ⚠️ **调用方不得提前清 g_bt_dev**:BT 打开成功时 dev_open() 返回 0,谁提前清 0, + * 这里就会落到下面 close(g_dev_fd) 分支上误关 stdin(M50 r1 评审 P2-1 就是这么复现的); + * - 非 BT 分支只关 fd>2 的真 fd(0/1/2 一律不动,第二道保险)。 */ + int kind = bt_dev_close_kind(g_bt_dev, g_dev_fd); + if (kind == 1) { bt_close(); g_bt_dev = 0; g_dev_fd = -1; g_dev_open = 0; return; } + if (kind == 0) { g_dev_fd = -1; g_dev_open = 0; return; } if (g_sink == SINK_FILE && g_dev_fd >= 0 && g_dev_fd != -2) { off_t end = lseek(g_dev_fd, 0, SEEK_END); if (end > 44) { @@ -1641,7 +1674,10 @@ static void *playback_thread(void *arg) pthread_mutex_lock(&g_mu); if (g_bt_fail_req) { g_bt_fail_req = 0; - g_bt_dev = 0; + /* **不要**在这里清 g_bt_dev:dev_close() 靠它认出「当前设备是 BT PCM」并走 + * BT 分支清理;提前清 0 会让它落到 close(g_dev_fd),而 BT 打开时 g_dev_fd==0 + * →误关 stdin(M50 r1 评审 P2-1)。g_dev_fd 同时置 -1 做第二道保险。 */ + g_dev_fd = -1; bt_note_fail("write path fatal (endpoint gone?)"); g_reconfig_pending = 1; pthread_cond_signal(&g_cond); diff --git a/patch_v2/meow_audiod/test_bt_dev.c b/patch_v2/meow_audiod/test_bt_dev.c index 0feeec5..242e30a 100644 --- a/patch_v2/meow_audiod/test_bt_dev.c +++ b/patch_v2/meow_audiod/test_bt_dev.c @@ -8,14 +8,16 @@ * 覆盖矩阵: * D1 bt_dev_detect_mac:真实 D-Bus 路径 → MAC;多端点/多设备取第一个 a2dpsrc; * hfphf/a2dpsnk 不算;简化 `a2dpsrc/` 兼容;空/无源端点/NULL/坏 dev_ 段 → -1。 - * D2 bt_dev_count_src:0/1/2 个源端点。 + * D3 bt_dev_codec_token:settings bt.codec → bluealsa canonical(4.1.1 别名表 strcasecmp); * auto/空/unchanged/垃圾 → unchanged。 * D4 bt_dev_pcm_name:内联 DEV/PROFILE/CODEC;坏 MAC/小缓冲 → -1。 * D5 bt_dev_backoff_ms / should_try:2s→60s 封顶 + 门控(防重试风暴)。 - * D6 bt_dev_endpoint_path:取整条 D-Bus 路径给 bluealsa-cli volume|info(真机行含 /sink)。 * D7 bt_dev_info_codec:真机 `bluealsa-cli info` 原文 → Selected codec(不能误吃 Available 行)。 - * D8 bt_dev_acl_mac:真机 `hcitool con` 原文 → ACL 对端 MAC(MAC 主来源;list-pcms 只在秒级窗口存在)。 + * D8 bt_dev_acl_macs:真机 `hcitool con` 原文 → **全部** ACL 对端 MAC(多设备一等公民: + * 只取第一条会在手机/手柄同时连着时挑错设备,导致有耳机却回落 DAC)。 + * D9 bt_dev_pick_endpoint:按 MAC 精确挑端点(换耳机 A→B 后不能拿 A 的路径发 volume/info)。 + * D10 bt_dev_close_kind:关闭决策(BT 分支 / 真 fd / **0-2 一律不动**=护 stdin,P2-1 回归)。 * * 编译(在 patch_v2/meow_audiod 下): * cc -Wall -Wextra -o test_bt_dev test_bt_dev.c bt_dev.c @@ -95,24 +97,6 @@ int main(void) check(rc == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0, "D1i trailing token tolerated"); } - /* ==== D2 源端点计数(有无 A2DP 端点的快速判定) ==== */ - check(bt_dev_count_src("") == 0, "D2a empty -> 0"); - check(bt_dev_count_src( - "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsnk/sink\n") == 0, - "D2b only sink -> 0"); - check(bt_dev_count_src( - "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/source\n") == 1, - "D2c one a2dpsrc -> 1"); - /* 行内判定回归:hfphf 行在 a2dpsrc 行之前,不能被算成源端点(整串 strstr 会误判) */ - check(bt_dev_count_src( - "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/hfphf/source\n" - "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n") == 1, - "D2e hfphf line before a2dpsrc -> count 1 (per-line match, not whole-buffer)"); - check(bt_dev_count_src( - "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_FF/a2dpsrc/source\n" - "/org/bluealsa/hci0/dev_11_22_33_44_55_66/a2dpsrc/source\n") == 2, - "D2d two a2dpsrc -> 2"); - /* ==== D3 settings bt.codec → bluealsa canonical token ==== */ { struct { const char *in; const char *want; const char *name; } t[] = { @@ -168,30 +152,6 @@ int main(void) check(bt_dev_pcm_name("33:DE:2E:21:AD:30", "LDAC", n, 16) == -1, "D4g tiny buffer -> -1"); } - /* ==== D6 端点完整路径(volume/info 要 D-Bus 路径,不是 MAC) ==== */ - { - char path[128] = ""; - int rc = bt_dev_endpoint_path( - "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n", path, sizeof path); - check(rc == 0 && strcmp(path, "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink") == 0, - "D6a real /a2dpsrc/sink line -> full path (trailing newline trimmed)"); - } - { - char path[128] = ""; - int rc = bt_dev_endpoint_path( - "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/hfphf/source\n" - "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\r\n", path, sizeof path); - check(rc == 0 && strcmp(path, "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink") == 0, - "D6b skips hfphf, strips CRLF (no newline leaks into popen cmd)"); - } - { - char path[128] = ""; - check(bt_dev_endpoint_path("/org/bluealsa/hci0/dev_11_22_33_44_55_66/a2dpsnk/sink\n", - path, sizeof path) == -1, "D6c no a2dpsrc -> -1"); - check(bt_dev_endpoint_path("", path, sizeof path) == -1, "D6d empty -> -1"); - check(bt_dev_endpoint_path(NULL, path, sizeof path) == -1, "D6e NULL -> -1"); - } - /* ==== D7 实际协商 codec(真机 info 原文:MOONDROP Pudding 只有 SBC/AAC,请求 LDAC 实际选 AAC) ==== */ { const char *info = @@ -226,36 +186,85 @@ int main(void) check(bt_dev_info_codec(NULL, codec, sizeof codec) == -1, "D7d NULL -> -1"); } - /* ==== D8 hcitool con → ACL MAC(真机原文;MAC 主来源) ==== */ + /* ==== D8 hcitool con → 全部 ACL MAC(真机原文;MAC 主来源,多设备是常态) ==== */ { const char *con = "Connections:\n" "\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER AUTH ENCRYPT \n"; - char mac[32] = ""; - check(bt_dev_acl_mac(con, mac, sizeof mac) == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0, - "D8a real hcitool con (state 1 AUTH ENCRYPT) -> ACL MAC"); + char m[4][18]; + check(bt_dev_acl_macs(con, m, 4) == 1 && strcmp(m[0], "33:DE:2E:21:AD:30") == 0, + "D8a real hcitool con (state 1 AUTH ENCRYPT) -> 1 ACL MAC"); } { const char *con = "Connections:\n" "\t< ACL 33:DE:2E:21:AD:30 handle 0 state 5 lm MASTER \n"; - char mac[32] = ""; - check(bt_dev_acl_mac(con, mac, sizeof mac) == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0, + char m[4][18]; + check(bt_dev_acl_macs(con, m, 4) == 1 && strcmp(m[0], "33:DE:2E:21:AD:30") == 0, "D8b half-open ACL (state 5) still gives MAC (kernel is authority)"); } { - const char *con = "Connections:\n"; - char mac[32] = ""; - check(bt_dev_acl_mac(con, mac, sizeof mac) == -1 && mac[0] == 0, "D8c no ACL -> -1"); - check(bt_dev_acl_mac(NULL, mac, sizeof mac) == -1, "D8d NULL -> -1"); + /* 多设备:第二副耳机 + 手机同时连着 —— 两条都要拿到,且顺序=输出顺序 */ + const char *con = + "Connections:\n" + "\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER AUTH ENCRYPT \n" + "\t< ACL 5C:FB:7C:11:22:33 handle 5 state 1 lm SLAVE \n"; + char m[4][18]; + int n = bt_dev_acl_macs(con, m, 4); + check(n == 2 && strcmp(m[0], "33:DE:2E:21:AD:30") == 0 && strcmp(m[1], "5C:FB:7C:11:22:33") == 0, + "D8c two ACLs -> both MACs in order (not just the first one)"); } { - /* 小写输入归一为大写(bluealsa DEV 参数按大写比较) */ - const char *con = "\t< ACL aa:bb:cc:dd:ee:ff handle 3 state 1 lm SLAVE \n"; - char mac[32] = ""; - check(bt_dev_acl_mac(con, mac, sizeof mac) == 0 && strcmp(mac, "AA:BB:CC:DD:EE:FF") == 0, - "D8e lowercase -> uppercase canonical"); + /* 三设备 + 上限裁剪 + 去重(同一设备多 profile 行重复出现) */ + const char *con = + "Connections:\n" + "\t< ACL AA:BB:CC:DD:EE:01 handle 1 state 1 lm MASTER \n" + "\t< ACL AA:BB:CC:DD:EE:01 handle 2 state 1 lm SLAVE \n" + "\t< ACL AA:BB:CC:DD:EE:02 handle 3 state 1 lm MASTER \n" + "\t< ACL AA:BB:CC:DD:EE:03 handle 4 state 1 lm MASTER \n"; + char m[3][18]; + int n = bt_dev_acl_macs(con, m, 3); + check(n == 3 && strcmp(m[0], "AA:BB:CC:DD:EE:01") == 0 && + strcmp(m[1], "AA:BB:CC:DD:EE:02") == 0 && strcmp(m[2], "AA:BB:CC:DD:EE:03") == 0, + "D8d dedup + cap at max (3 distinct)"); } + { + char m[4][18]; + check(bt_dev_acl_macs("Connections:\n", m, 4) == 0, "D8e no ACL -> 0"); + check(bt_dev_acl_macs(NULL, m, 4) == 0, "D8f NULL -> 0"); + check(bt_dev_acl_macs("\t< ACL aa:bb:cc:dd:ee:ff handle 3 state 1 lm SLAVE \n", m, 4) == 1 && + strcmp(m[0], "AA:BB:CC:DD:EE:FF") == 0, "D8g lowercase -> uppercase canonical"); + } + + /* ==== D9 按 MAC 精确挑端点(换耳机 A→B 不串台) ==== */ + { + const char *pcms = + "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_01/a2dpsrc/sink\n" + "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_02/a2dpsrc/sink\n"; + char path[128] = ""; + check(bt_dev_pick_endpoint(pcms, "AA:BB:CC:DD:EE:02", path, sizeof path) == 0 && + strcmp(path, "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_02/a2dpsrc/sink") == 0, + "D9a picks B's path when MAC=B (not the first endpoint)"); + check(bt_dev_pick_endpoint(pcms, "AA:BB:CC:DD:EE:01", path, sizeof path) == 0 && + strcmp(path, "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_01/a2dpsrc/sink") == 0, + "D9b picks A's path when MAC=A"); + check(bt_dev_pick_endpoint(pcms, "AA:BB:CC:DD:EE:03", path, sizeof path) == -1 && + path[0] == 0, "D9c unknown MAC -> -1 (never borrow another device's path)"); + check(bt_dev_pick_endpoint("/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_01/hfphf/source\n", + "AA:BB:CC:DD:EE:01", path, sizeof path) == -1, + "D9d non-a2dpsrc endpoint -> -1"); + check(bt_dev_pick_endpoint(NULL, "AA:BB:CC:DD:EE:01", path, sizeof path) == -1, "D9e NULL -> -1"); + } + + /* ==== D10 关闭决策(P2-1 回归:0/1/2 永不当设备 fd 关) ==== */ + check(bt_dev_close_kind(1, 0) == 1, "D10a bt_dev=1 + fd=0 -> close BT only (never fd 0)"); + check(bt_dev_close_kind(1, 5) == 1, "D10b bt_dev=1 + fd=5 -> close BT only"); + check(bt_dev_close_kind(0, 0) == 0, "D10c bt_dev=0 + fd=0 -> NONE (stdin guard, P2-1)"); + check(bt_dev_close_kind(0, 1) == 0, "D10d bt_dev=0 + fd=1 -> NONE (stdout guard)"); + check(bt_dev_close_kind(0, 2) == 0, "D10e bt_dev=0 + fd=2 -> NONE (stderr guard)"); + check(bt_dev_close_kind(0, 3) == 2, "D10f bt_dev=0 + fd=3 -> close FD"); + check(bt_dev_close_kind(0, -1) == 0, "D10g bt_dev=0 + fd=-1 -> NONE"); + check(bt_dev_close_kind(0, -2) == 0, "D10h bt_dev=0 + fd=-2 (NULL sink) -> NONE"); /* ==== D5 回落退避(防重试风暴:M10 教训) ==== */ check(bt_dev_backoff_ms(0) == 0, "D5a fails=0 -> 0ms (immediate)");