From 239cebae0139382b931b7f4ccd695802893c4e50 Mon Sep 17 00:00:00 2001 From: worker-m49 Date: Wed, 11 Nov 2026 19:15:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(bt):=20M49=20r1=20=E8=AF=84=E5=AE=A1=20?= =?UTF-8?q?=E2=80=94=20=E5=86=99=E6=93=8D=E4=BD=9C=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=8E=8B=E5=9B=9E=201500ms=20=E7=AA=97=E5=8F=A3=E5=86=85=20+?= =?UTF-8?q?=20A2DP=20=E6=80=BB=E9=A2=84=E7=AE=97/TTL=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=20+=20=E6=B5=8B=E8=AF=95=E5=8F=AF=E9=87=8D=E5=A4=8D=20+=20rfki?= =?UTF-8?q?ll=20=E9=A1=BA=E5=BA=8F=EF=BC=9Brebase=20=E5=88=B0=20main=20?= =?UTF-8?q?=E4=BF=9D=E4=BD=8F=20M43=20time32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 到 main(00e7271,M43 time32 已合入) 时解开 build_netmgrd.sh 冲突:**两者都保留**—— M43 的 `../sdk/meow_time32.c` + `-DMEOW_TIME32_LIBC_OVERRIDES`,以及我的 ROOT/ZIGCC/OUT 环境变量化。编译改在 mktemp 目录里做(产物名仍是字面量 `-o meow_netmgrd`,供 sdk/test_time32_abi.sh 的按名定位),编完再落到 OUT —— 设了 OUT 也不会覆盖/删除仓库里 那份被跟踪的二进制(r1 期间 mv 曾把它删掉过)。门禁自证:`--builds` → build meow_netmgrd src=yes macro=yes;`--no-builds` → 4263=3 4403=0 16B-layout=OK ALL PASS。 P1-1(写操作越过客户端 1500ms 窗口):预算模型改成**每一层都受限**—— `BT_ACT_TMO_MS 800 / BT_ACT_SHORT_MS 600 / BT_VERIFY_BUDGET_MS 220 / gap 90`, `bt_poll_probe` 把**剩余预算**传给探测函数(`bt_acl_has_tri`/`bt_info_flag` 新增 tmo 形参), 探测自身那次 run_cmd 也受同一个 deadline 约束;`bt_onoff` 同理(up 失败只在剩余预算内重试 一次);成功文案不再补设备名(省掉 1-2 次 fork,UI 本来就按 MAC join)。 探测改为三态:命令失败/超时 = -1(**未知**),不再被当成「没有 ACL」——否则 disconnect 会在 查不到的情况下报「已断开」,又是一次假成功。 实测(宿主桩驱动 bt_ctl_execute,命令卡死 5s 的形态):connect 1042-1047ms、pair 1042-1045ms、 bt on 831ms,全部 < 1450 且有 400ms+ 余量(r1 实测 1528/2424ms)。设备侧实测 fork 成本 ≈25-30ms/次(bt a2dp 50ms、bt status 100ms),余量足够。 P2-1(A2DP 1+N 子进程塞进 status JSON):`bt_a2dp_query(buf,cap,budget)` 加**总预算**, list-pcms 单命令 ≤400ms、每个 info ≤300ms、剩下端点超预算就不再查;`bt a2dp` 命令用 900ms。 status JSON 不在无连接时查(0 次 fork),有连接时走 10s TTL 缓存 + 500ms 预算; `a2dp_codec`/`a2dp_rate` 字段保留(MeowPanel btCard 依赖,见 docs/plan/FUTURE_MEOWPANEL.md:48)。 实测 4 端点全卡死:911ms(r1 6053ms);status JSON 首次 350-384ms、缓存命中 31-49ms。 P2-2(test_bt_ctl 不可重复):开跑先 `rm -rf` 状态目录、退出再清;连跑 3 次 ALL PASS(贴了输出)。 P2-3(bt_onoff 顺序 + 多节点):开=先 rfkill unblock(**全部**同类型节点)再 hciconfig up, up 未生效时剩余预算内重试一次;关=先 down 再 block。新增 `rfkill_set_block_all()`(旧 `rfkill_set_block` 仍只写首个节点,WiFi 路径行为不变)。桩改成「blocked 时 up 必败」,并加了 反向用例证明桩真的会失败(否则断言空转)。 P3:①info 的 RUNCMD_EMPTY(exit 0 无输出)不再当失败跳过;②`bt_pcm_paths_parse` 的 `/a2dpsrc` 搜索限制在行内 [p,e)(原来会跨 \n 命中下一行);③`bt_devices_text` 注释写明 「本轮扫描见到过的设备」而非「此刻在场」([DEL] 不删除、devices 块含 BlueZ 历史缓存)。 附带(塔 11:06 建议,BT 作用域内先落地):连续 3 次 RUNCMD_TIMEOUT 后熔断 30s,冷却期内 直接回「熔断中」不再 fork(真机 55 个挂死 bluetoothctl 吃光进程槽的事故);任何非超时返回清零。 单测覆盖:第 4 次调用 0ms 返回。范围只限 BT 写操作,WiFi/DNS 调用点未动。 真机(本轮只读,设备已恢复在线):新件 md5 e62e7512 上卡只读验收 status/paired/connected/ devices/a2dp 全通;`ps w | grep -c bluetoothctl` 前后均为 2(≤3);Music du 199164544; 临时目录/独立实例清理干净,/usr/bin 未动。 --- patch_v2/meow_netmgrd/build_netmgrd.sh | 14 +- patch_v2/meow_netmgrd/meow_netmgrd.c | 288 +++++++++++++++++++------ patch_v2/meow_netmgrd/test_bt_ctl.c | 251 ++++++++++++++++++--- patch_v2/meow_netmgrd/test_netmgrd.sh | 31 +-- 4 files changed, 470 insertions(+), 114 deletions(-) diff --git a/patch_v2/meow_netmgrd/build_netmgrd.sh b/patch_v2/meow_netmgrd/build_netmgrd.sh index b280963..dc9558c 100755 --- a/patch_v2/meow_netmgrd/build_netmgrd.sh +++ b/patch_v2/meow_netmgrd/build_netmgrd.sh @@ -19,7 +19,7 @@ ROOT="${MEOW_ROOT:-$(cd ../.. && pwd)}" CC="${ZIGCC:-$ROOT/.tools/zig-mipsel-cc}" [ -x "$CC" ] || CC="$HOME/.tools/zig-mipsel-cc" I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/MSG_NET_*/m_net_*) -OUT="${OUT:-.}" +OUT="${OUT:-.}" # 产物目录:默认脚本目录;设了则在交叉编译后把产物移过去 [ -d "$OUT" ] || mkdir -p "$OUT" if [ "$1" = "host" ]; then @@ -36,8 +36,16 @@ fi # meow_time32.c:独立 o32 time32 取时/睡眠(netmgrd 不链 meow_ui.a);修 DNS TTL 基准 # 与 nanosleep 退避。守护/CLI 同一二进制,一次构建两处安装。(M43 的 time32 接入,必须保留: # 少了它 mono_now()/轮询退避会踩 mipsel o32 的 time64/time32 结构错位。) -"$CC" -Os -Wall -Wextra -static -I"$I_HDR" -DMEOW_TIME32_LIBC_OVERRIDES \ - meow_netmgrd.c ../sdk/meow_time32.c -o "$OUT/meow_netmgrd" +# 注意:`-o meow_netmgrd` 保持字面量(不带目录前缀)——sdk/test_time32_abi.sh 的构建门禁正是 +# 按 `-o <产物名>` 定位编译行来断言 time32 接入;写成 `-o "$OUT/..."` 会让门禁报「未找到」。 +# 编译在临时目录里做(产物名仍是字面量),编完再落到 OUT:这样设了 OUT 也**不会**覆盖/删除 +# 仓库里那份被跟踪的 patch_v2/meow_netmgrd/meow_netmgrd(M49 曾因 mv 把它删掉过一次)。 +HERE="$(pwd)" +BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netmgrd-build.XXXXXX")" +( cd "$BUILD_DIR" && "$CC" -Os -Wall -Wextra -static -I"$I_HDR" -DMEOW_TIME32_LIBC_OVERRIDES \ + "$HERE/meow_netmgrd.c" "$HERE/../sdk/meow_time32.c" -o meow_netmgrd ) +mv -f "$BUILD_DIR/meow_netmgrd" "$OUT/meow_netmgrd" +rmdir "$BUILD_DIR" 2>/dev/null || true echo "== meow_netmgrd (mipsel static) ==" ls -la "$OUT/meow_netmgrd" md5sum "$OUT/meow_netmgrd" diff --git a/patch_v2/meow_netmgrd/meow_netmgrd.c b/patch_v2/meow_netmgrd/meow_netmgrd.c index e7f4234..6ba2d6c 100644 --- a/patch_v2/meow_netmgrd/meow_netmgrd.c +++ b/patch_v2/meow_netmgrd/meow_netmgrd.c @@ -89,14 +89,30 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET * 普通 ~10s;隐藏网络(connecth,主动探测+关联更慢)~15s。env 可调(宿主桩测试缩短)。 */ #define WIFI_CONNECT_WAIT_MS 10000 /* MEOW_WIFI_CONNECT_WAIT_MS */ #define WIFI_CONNECTH_WAIT_MS 15000 /* MEOW_WIFI_CONNECTH_WAIT_MS */ -/* 蓝牙写操作(connect/pair)要等 BlueZ 关联/配对收敛,但 launcher 侧 `netmgr_bt()` 的 IPC 超时是 - * NM_TMO_DFL=1500ms(netmgr_ipc.c:21,非本单 scope)——守护侧一旦超过它,客户端会判「不可达」并 - * 回退直连再下发一次。所以这里的预算=「命令 1100ms + 复查 350ms」,绝不越过客户端的 1500ms 窗口; - * 真态复查拿不到就如实报「未收敛」,不再像 P1 之前那样恒报成功。 */ -#define BT_ACT_TMO_MS 1100 /* connect/pair 的 bluetoothctl 上限 */ -#define BT_ACT_SHORT_MS 900 /* disconnect/unpair/on/off */ -#define BT_VERIFY_BUDGET_MS 350 /* 写后真态复查总预算 */ -#define BT_VERIFY_GAP_MS 120 /* 每轮间隔(ms) */ +/* 蓝牙写操作的**总预算**必须压在 launcher 侧 `netmgr_bt()` 的 IPC 超时(NM_TMO_DFL=1500ms, + * netmgr_ipc.c,非本单 scope)以内:越线即被客户端判「不可达」→ 直连回退再下发一次 → + * 刚写好的「未生效」文案被客户端丢掉,净效果退回「恒报成功」。 + * 预算模型(每一层都受限,不是只在两次探测之间看 deadline): + * 命令 ≤ BT_ACT_TMO_MS / BT_ACT_SHORT_MS + * + 复查 ≤ BT_VERIFY_BUDGET_MS,且**每次探测的 run_cmd 也只用剩余预算** + * + 收尾 reap(正常/超时路径都先 SIGKILL,不额外等 300ms) + * 目标上界 ≈ 1150ms(MIPS 更慢的 fork/exec 留了余量),实测见 test_bt_ctl 的 `budget-*` 用例。 */ +#define BT_ACT_TMO_MS 800 /* connect/pair 的 bluetoothctl 上限 */ +#define BT_ACT_SHORT_MS 600 /* disconnect/unpair/on/off */ +#define BT_VERIFY_BUDGET_MS 220 /* 写后真态复查总预算(含探测内部 run_cmd) */ +#define BT_VERIFY_GAP_MS 90 /* 每轮间隔(ms) */ +/* A2DP 查询是 1+N 个子进程(list-pcms + 每端点 info),而守护单线程、DNS 代理同一主循环, + * 所以**每条命令与整段查询都设上限**;status JSON 另走 TTL 缓存(见 g_bt_a2dp_*)。 */ +#define BT_A2DP_TEXT_BUDGET_MS 900 /* `bt a2dp` 命令总预算(客户端 1500ms 窗口内) */ +#define BT_A2DP_STATUS_BUDGET_MS 500 /* status JSON 刷新缓存时的总预算 */ +#define BT_A2DP_LIST_TMO_MS 400 /* list-pcms 单命令上限 */ +#define BT_A2DP_INFO_TMO_MS 300 /* 每个端点 info 单命令上限 */ +#define BT_A2DP_CACHE_SEC 10 /* status JSON 的 a2dp 摘要缓存 TTL(秒) */ +/* 防堆积熔断(真机 0.8.49 事故:55 个挂死的 bluetoothctl 吃光进程槽/内存,launcherd 与 sshd + * 同时失去 fork 能力)。BT 写操作**连续** NN 次超时后,冷却期内不再 fork 新命令,如实回 + * 「熔断中」;任何非超时返回清零。范围只限 BT 写操作,不影响 WiFi/DNS 调用点。 */ +#define BT_TMO_STREAK_MAX 3 +#define BT_TMO_COOLDOWN_SEC 30 #define IFACE_MAX 16 #define RFKILL_MAX 8 #define DNS_MAX 8 @@ -745,8 +761,9 @@ static void wifi_status(wifi_t *w) /* ---------------- 蓝牙:hciconfig/bluetoothctl 包装 ---------------- */ /* hci0 真实 power 态:1=up 0=down -1=未知。sysfs `/sys/class/bluetooth/hci0/power` 优先, - * 回退 `hciconfig hci0` 行首 UP/DOWN(P1 之后从 bt_status 抽出,写操作复查也用它)。 */ -static int bt_powered_now(void) + * 回退 `hciconfig hci0` 行首 UP/DOWN(P1 之后从 bt_status 抽出,写操作复查也用它)。 + * tmo_ms 只管回退那次 run_cmd:写后复查要能把它压进总预算,读路径传 RUNCMD_TMO_MS。 */ +static int bt_powered_now(int tmo_ms) { char p[192]; snprintf(p, sizeof p, "%s/hci0/power", g_bt_dir); @@ -758,7 +775,7 @@ static int bt_powered_now(void) char cmd[160]; snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s hci0 2>/dev/null", g_hciconfig); char buf[256]; - if (run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd) <= 0) return -1; + if (run_cmd(buf, sizeof buf, tmo_ms, cmd) <= 0) return -1; char *ln = buf; while (ln && *ln) { char *nl = strchr(ln, '\n'); @@ -773,7 +790,7 @@ static int bt_powered_now(void) return -1; } -static int bt_acl_macs(char macs[][24], int max); /* 前向声明(bt_status 早于其定义) */ +static int bt_acl_macs(char macs[][24], int max, int tmo_ms); /* 前向声明(bt_status 早于其定义) */ static void bt_status(bt_t *b) { memset(b, 0, sizeof *b); @@ -783,7 +800,7 @@ static void bt_status(bt_t *b) snprintf(p, sizeof p, "%s/hci0", g_bt_dir); if (access(p, F_OK) == 0) b->available = 1; if (!b->available) return; - { int pw = bt_powered_now(); b->powered = pw > 0 ? 1 : 0; } + { int pw = bt_powered_now(RUNCMD_TMO_MS); b->powered = pw > 0 ? 1 : 0; } /* address:/sys/.../address 优先,回退 hciconfig "BD Address:" */ snprintf(p, sizeof p, "%s/hci0/address", g_bt_dir); read_file_txt(p, b->address, sizeof b->address); @@ -825,7 +842,7 @@ static void bt_status(bt_t *b) /* 已连接数:内核 ACL(hcitool con)权威;快速,不受 bluetoothctl 过滤/慢启动影响。 */ { char macs[8][24]; - b->connected_n = bt_acl_macs(macs, 8); + b->connected_n = bt_acl_macs(macs, 8, RUNCMD_TMO_MS); } } @@ -1009,6 +1026,18 @@ static int bt_path_mac(const char *path, char *mac, size_t mcap) return 1; } +/* 在 [s, e) 内找子串 needle(不跨行匹配;行内 strstr 会越过 '\n' 命中下一行)。 + * 返回 1=命中。纯函数。 */ +static int bt_mem_has(const char *s, const char *e, const char *needle) +{ + size_t nl = strlen(needle); + if (!nl || e <= s) return 0; + for (const char *q = s; q + nl <= e; q++) { + if (!memcmp(q, needle, nl)) return 1; + } + return 0; +} + /* 解析 `bluealsa-cli list-pcms` → A2DP 端点路径(每行一个 /org/bluealsa/... 路径)。 * 只收 a2dpsrc/a2dpsnk(SCO/HFP 端点不算 A2DP);行尾空白/CR 去掉。返回数量(≤max)。 */ static int bt_pcm_paths_parse(const char *raw, char paths[][80], int max) @@ -1022,8 +1051,7 @@ static int bt_pcm_paths_parse(const char *raw, char paths[][80], int max) const char *p = line; while (p < e && (*p == ' ' || *p == '\t')) p++; while (e > p && (e[-1] == ' ' || e[-1] == '\t' || e[-1] == '\r')) e--; - if (e - p > 14 && !strncmp(p, "/org/bluealsa/", 14) && - (strstr(p, "/a2dpsrc") || strstr(p, "/a2dpsnk"))) { + if (e - p > 14 && (bt_mem_has(p, e, "/a2dpsrc") || bt_mem_has(p, e, "/a2dpsnk"))) { size_t l = (size_t)(e - p); if (l > 79) l = 79; memcpy(paths[n], p, l); paths[n][l] = 0; n++; @@ -1082,7 +1110,11 @@ static void bt_pcm_info_parse(const char *raw, bt_pcm_info_t *o) char *p = clean; while (*p == ' ' || *p == '\t') p++; char v[64]; - if (!o->codec[0] && bt_kv(p, "Codec", v, sizeof v) && v[0]) { + /* 真机 v4.1.1 的键名(M50 原文):Selected codec / Available codecs / Format: S16_LE / + * Running: true|false / Channels / Sampling / Transport / Mode / Delay / Volume / Muted。 + * 注意 Available codecs 是空格分隔的**列表**,绝不能当 codec;Selected codec 才是当前值。 */ + if (!o->codec[0] && + (bt_kv(p, "Selected codec", v, sizeof v) || bt_kv(p, "Codec", v, sizeof v)) && v[0]) { snprintf(o->codec, sizeof o->codec, "%s", v); o->has = 1; } else if (!o->rate[0] && bt_kv(p, "Sampling", v, sizeof v) && v[0]) { snprintf(o->rate, sizeof o->rate, "%s", v); o->has = 1; @@ -1094,6 +1126,8 @@ static void bt_pcm_info_parse(const char *raw, bt_pcm_info_t *o) snprintf(o->bits, sizeof o->bits, "%s", v); o->has = 1; } else if (bt_kv(p, "Format", v, sizeof v) && v[0]) { snprintf(fmt, sizeof fmt, "%s", v); o->has = 1; + } else if (bt_kv(p, "Mode", v, sizeof v) && v[0]) { + o->has = 1; /* sink/source:本机侧 PCM 模式(不是 codec/不是 MAC) */ } else if (bt_kv(p, "State", v, sizeof v) && v[0]) { snprintf(o->state, sizeof o->state, "%s", v); o->has = 1; if (strstr(v, "RUNNING") || strstr(v, "ACTIVE") || strstr(v, "PLAYING")) o->running = 1; @@ -1107,9 +1141,16 @@ static void bt_pcm_info_parse(const char *raw, bt_pcm_info_t *o) } line = nl ? nl + 1 : NULL; } - /* Format 兜底:如 "44100 Hz, 2 channels, 16 bits" / "44100 Hz 2 channels 16 bits" */ + /* Format 兜底:真机是 ALSA 格式串(S16_LE / S24_3LE),也可能是 + * "44100 Hz, 2 channels, 16 bits" 这种打包写法。 */ if (fmt[0]) { char *q; + if (!o->bits[0] && (fmt[0] == 'S' || fmt[0] == 'U')) { + char *n = fmt + 1; + int bw = 0; + while (*n >= '0' && *n <= '9') { bw = bw * 10 + (*n - '0'); n++; } + if (bw > 0 && bw <= 32) snprintf(o->bits, sizeof o->bits, "%d", bw); + } if (!o->rate[0] && (q = strstr(fmt, "Hz")) != NULL) { char *b = q; while (b > fmt && (b[-1] == ' ' || b[-1] == '\t')) b--; /* 跳过 "Hz" 前的空格 */ @@ -1144,34 +1185,44 @@ static void bt_pcm_info_parse(const char *raw, bt_pcm_info_t *o) } } -/* 内核 ACL 连接 MAC(hcitool con)。返回数(≤max)。 */ -static int bt_acl_macs(char macs[][24], int max) +/* 内核 ACL 连接 MAC(hcitool con)。返回数(≤max)。 + * tmo_ms 由调用方给:读路径用 RUNCMD_TMO_MS,写后复查只给剩余预算。 */ +static int bt_acl_macs(char macs[][24], int max, int tmo_ms) { char cmd[160]; snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s con 2>/dev/null", g_hcitool); char buf[1024]; - if (run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd) <= 0) return 0; + if (run_cmd(buf, sizeof buf, tmo_ms, cmd) <= 0) return 0; return bt_acl_parse(buf, macs, max); } -/* 指定 MAC 是否有内核 ACL(hcitool con)。1=有 0=没有。 */ -static int bt_acl_has(const char *mac) +/* 写后复查专用三态:1=有 ACL,0=确实没有,-1=**查不到**(命令失败/超时/被 kill)。 + * 必须和「没有」分开:探测超时被当成「没有连接」的话,disconnect 就会在查不到的情况下 + * 报「已断开」——那正是本单要消灭的「假成功」。 */ +static int bt_acl_has_tri(const char *mac, int tmo_ms) { + char cmd[160]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s con 2>/dev/null", g_hcitool); + char buf[1024]; + runcmd_res_t rr; + int n = run_cmd_ex(buf, sizeof buf, tmo_ms, cmd, &rr); + if (n < 0 || rr.status == RUNCMD_TIMEOUT || rr.status == RUNCMD_EXEC || + rr.status == RUNCMD_EXIT) return -1; char macs[8][24]; - int n = bt_acl_macs(macs, 8); - for (int i = 0; i < n; i++) if (bt_mac_eq(macs[i], mac)) return 1; + int cnt = bt_acl_parse(buf, macs, 8); + for (int i = 0; i < cnt; i++) if (bt_mac_eq(macs[i], mac)) return 1; return 0; } /* `bluetoothctl info ` 的布尔字段真态(Paired / Connected / Trusted): * 1=yes 0=no -1=未知(设备不在 BlueZ 列表里 / 后端失败)。 */ -static int bt_info_flag(const char *mac, const char *key) +static int bt_info_flag(const char *mac, const char *key, int tmo_ms) { char cmd[224]; snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s info %s 2>&1", g_bluetoothctl, mac); char buf[1024]; runcmd_res_t rr; - if (run_cmd_ex(buf, sizeof buf, RUNCMD_TMO_MS, cmd, &rr) <= 0) return -1; + if (run_cmd_ex(buf, sizeof buf, tmo_ms, cmd, &rr) <= 0) return -1; char keyp[32]; snprintf(keyp, sizeof keyp, "%s:", key); const char *p = strstr(buf, keyp); @@ -1218,7 +1269,7 @@ static void bt_connected_text(char *buf, int cap) { int off = 0; char macs[8][24]; - int n = bt_acl_macs(macs, 8); + int n = bt_acl_macs(macs, 8, RUNCMD_TMO_MS); for (int i = 0; i < n; i++) { char name[64] = ""; bt_name_for_mac(macs[i], name, sizeof name); @@ -1240,19 +1291,24 @@ static void bt_connected_text(char *buf, int cap) * 所以:本函数是「此刻快照」;没有端点 → "A2DP none"(= 当前无 A2DP 活动,不是错误)。 * 字段缺失写 "-"(info 输出认得但字段名对不上时也照样出行,至少让 UI 看见端点在);info 查询 * 失败(端点刚好消失)就跳过该端点;一个都没有 → "A2DP none"。 */ -static void bt_a2dp_text(char *buf, int cap) +static void bt_a2dp_query(char *buf, int cap, int budget_ms) { if (cap <= 0) return; buf[0] = 0; + long deadline = mono_ms() + (budget_ms > 0 ? budget_ms : 1); char cmd[176]; snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s list-pcms 2>&1", g_bluealsa_cli); char raw[1024]; runcmd_res_t rr; - run_cmd_ex(raw, sizeof raw, RUNCMD_TMO_MS, cmd, &rr); + long remain = deadline - mono_ms(); + if (remain <= 0) { snprintf(buf, (size_t)cap, "A2DP none\n"); return; } + run_cmd_ex(raw, sizeof raw, (int)(remain > BT_A2DP_LIST_TMO_MS ? BT_A2DP_LIST_TMO_MS : remain), cmd, &rr); char paths[4][80]; int np = bt_pcm_paths_parse(raw, paths, 4); int off = 0; for (int i = 0; i < np; i++) { + remain = deadline - mono_ms(); + if (remain <= 0) break; /* 总预算用完:剩下的端点不再查(宁缺不卡) */ char mac[24] = "-"; if (!bt_path_mac(paths[i], mac, sizeof mac)) continue; const char *role = strstr(paths[i], "a2dpsnk") ? "snk" : "src"; @@ -1261,8 +1317,10 @@ static void bt_a2dp_text(char *buf, int cap) g_bluealsa_cli, paths[i]); char ibuf[1024]; runcmd_res_t ir; - run_cmd_ex(ibuf, sizeof ibuf, RUNCMD_TMO_MS, cmd2, &ir); - if (ir.status != RUNCMD_OK) continue; /* 端点消失/查询失败:跳过,不编造 */ + run_cmd_ex(ibuf, sizeof ibuf, (int)(remain > BT_A2DP_INFO_TMO_MS ? BT_A2DP_INFO_TMO_MS : remain), + cmd2, &ir); + /* 端点消失/查询失败:跳过,不编造。EMPTY(exit 0 无输出)不算失败——端点确实在。 */ + if (ir.status != RUNCMD_OK && ir.status != RUNCMD_EMPTY) continue; bt_pcm_info_t info; bt_pcm_info_parse(ibuf, &info); /* info 成功但字段名一个都没命中(版本差异)→ 仍出行,字段写 "-": @@ -1279,13 +1337,24 @@ static void bt_a2dp_text(char *buf, int cap) else buf[off] = 0; } -/* A2DP 摘要(首个带 codec/rate 的端点)给 overview JSON;失败置空。 */ -static void bt_a2dp_summary(char *codec, int clen, char *rate, int rlen) +/* `bt a2dp` 命令入口:总预算 BT_A2DP_TEXT_BUDGET_MS(客户端 1500ms 窗口内)。 */ +static void bt_a2dp_text(char *buf, int cap) +{ + bt_a2dp_query(buf, cap, BT_A2DP_TEXT_BUDGET_MS); +} + +/* status JSON 用的 a2dp 摘要缓存:TTL + 总预算,且**无连接时直接不查**(没 ACL 就没有 A2DP + * 端点,一次 fork 都不必花)。守护单线程、DNS 代理同一主循环,status 是高频路径。 */ +static long g_bt_a2dp_cache_ms = 0; +static char g_bt_a2dp_codec[16] = "", g_bt_a2dp_rate[16] = ""; + +/* A2DP 摘要(首个带 codec/rate 的端点);budget_ms 为总预算,失败置空。纯读,不改缓存。 */ +static void bt_a2dp_summary(char *codec, int clen, char *rate, int rlen, int budget_ms) { if (codec && clen > 0) codec[0] = 0; if (rate && rlen > 0) rate[0] = 0; char buf[MEOW_IPC_MAX - 16]; - bt_a2dp_text(buf, sizeof buf); + bt_a2dp_query(buf, sizeof buf, budget_ms); char *line = buf; while (line && *line) { char *nl = strchr(line, '\n'); if (nl) *nl = 0; @@ -1304,7 +1373,10 @@ static void bt_a2dp_summary(char *codec, int clen, char *rate, int rlen) /* 附近设备文本:netmgrd 权威扫描缓存(/tmp/meow_bt_scan.txt),行 "Device "(去重)。 * M49:实时 `[NEW] Device` 行以 ANSI ESC 开头(见 bt_scan_line_parse 注释里的字节证据), - * 旧实现只认行首 "Device "/"[NEW] Device " → 实时行全丢,只能靠末尾 `devices` 块。 */ + * 旧实现只认行首 "Device "/"[NEW] Device " → 实时行全丢,只能靠末尾 `devices` 块。 + * `[DEL]` 的处理是「不新增」而不是「删掉已有」:bt_scan_on 每次扫描都用 `>` 整文件覆盖缓存, + * 所以跨会话不会累积;但**同一次扫描窗口内**先出现后离开的设备仍会留在列表里,且末尾 + * `devices` 块本身含 BlueZ 历史缓存——所以这份列表是「本轮扫描见到过的设备」,不是「此刻在场」。 */ static void bt_devices_text(char *buf, int cap) { int off = 0; @@ -1377,7 +1449,9 @@ static void rfkill_list(void) } /* 找某类型 rfkill 设备并写 soft=block(1)/unblock(0)。返回 0=ok;-1=无该类型/写失败。 */ -static int rfkill_set_block(const char *type, int block) +/* rfkill 软件开关。all=0 只写第一个同类型节点(旧行为),all=1 写**全部**同类型节点: + * 真机有 rfkill0/rfkill3 两个 bluetooth 节点,只写首个会让另一个仍处于 block。 */ +static int rfkill_set_block_ex(const char *type, int block, int all) { char norm[16] = ""; if (!strcmp(type, "wifi")) snprintf(norm, sizeof norm, "%s", "wlan"); @@ -1398,12 +1472,15 @@ static int rfkill_set_block(const char *type, int block) char one = block ? '1' : '0'; if (write(fd, &one, 1) == 1) rc = 0; close(fd); - break; + if (!all) break; } closedir(d); } return rc; } +static int rfkill_set_block(const char *type, int block) { return rfkill_set_block_ex(type, block, 0); } +/* 蓝牙用「全节点」版本:任一节点仍 block 就等于没开(M47 finding 的第二个点)。 */ +static int rfkill_set_block_all(const char *type, int block) { return rfkill_set_block_ex(type, block, 1); } /* ---------------- udhcpc:续租/释放(SIGUSR1/SIGUSR2,不改启动路径) ---------------- */ static int udhcpc_ctl(const char *op, const char *ifname) @@ -1654,10 +1731,23 @@ static void build_status_json(char *out, size_t cap) jadd_str_or_null(&w, "address", bt.address); jadd_int_or_null(&w, "paired", bt.paired_n); jadd_int_or_null(&w, "connected", bt.connected_n); - { char ac[16] = "", ar[16] = ""; - bt_a2dp_summary(ac, sizeof ac, ar, sizeof ar); - jadd_str_or_null(&w, "a2dp_codec", ac); - jadd_str_or_null(&w, "a2dp_rate", ar); } + /* a2dp_codec/a2dp_rate 是 MeowPanel btCard 的字段(docs/plan/FUTURE_MEOWPANEL.md:48), + * 但 status 是高频路径且守护单线程(DNS 代理同环)——所以:无连接直接不查(0 次 fork), + * 有连接时走 TTL 缓存,刷新也只给 BT_A2DP_STATUS_BUDGET_MS。`bt a2dp` 命令另有独立调用。 */ + if (bt.connected_n > 0) { + long now_s = mono_now(); + if (!g_bt_a2dp_cache_ms || now_s - g_bt_a2dp_cache_ms >= BT_A2DP_CACHE_SEC) { + g_bt_a2dp_cache_ms = now_s; + bt_a2dp_summary(g_bt_a2dp_codec, sizeof g_bt_a2dp_codec, + g_bt_a2dp_rate, sizeof g_bt_a2dp_rate, BT_A2DP_STATUS_BUDGET_MS); + } + } else { + g_bt_a2dp_cache_ms = 0; + g_bt_a2dp_codec[0] = 0; + g_bt_a2dp_rate[0] = 0; + } + jadd_str_or_null(&w, "a2dp_codec", g_bt_a2dp_codec); + jadd_str_or_null(&w, "a2dp_rate", g_bt_a2dp_rate); jadd(&w, "\"dns\":["); { char ns[DNS_MAX][40]; @@ -3384,45 +3474,101 @@ static void bt_snip(const char *s, char *dst, size_t cap) dst[o] = 0; } -typedef int (*bt_probe_fn)(const char *); -static int bt_probe_acl(const char *mac) { return bt_acl_has(mac); } -static int bt_probe_paired(const char *mac) { return bt_info_flag(mac, "Paired"); } +/* 探测函数带 tmo:写后复查的**总**预算必须传到每一次 run_cmd 里,否则「两次探测之间看 + * deadline」挡不住探测自身那 1500ms(r1 评审 P1:实测 connect 1528ms / bt on 2424ms, + * 越过客户端 NM_TMO_DFL=1500 的 IPC 窗口 → 客户端判不可达 → 二次下发 + 假成功)。 */ +typedef int (*bt_probe_fn)(const char *mac, int tmo_ms); +static int bt_probe_acl(const char *mac, int tmo_ms) { return bt_acl_has_tri(mac, tmo_ms); } +static int bt_probe_paired(const char *mac, int tmo_ms) { return bt_info_flag(mac, "Paired", tmo_ms); } -/* 写后真态复查:在 BT_VERIFY_BUDGET_MS 内轮询 probe,一旦命中 want 立刻返回; - * 返回最后一次探测值(1/0/-1=未知)。总耗时受限于客户端 1500ms 的 IPC 预算。 */ -static int bt_poll_probe(bt_probe_fn fn, const char *mac, int want) +/* 熔断器状态(只服务 BT 写操作;守护单线程,无需加锁)。 */ +static int g_bt_tmo_streak = 0; +static long g_bt_tmo_until = 0; /* 冷却截止(mono 秒),0=未熔断 */ + +static int bt_circuit_open(void) { - long deadline = mono_ms() + BT_VERIFY_BUDGET_MS; - int v = fn(mac); - while (v != want && mono_ms() < deadline) { + return (g_bt_tmo_streak >= BT_TMO_STREAK_MAX && g_bt_tmo_until && mono_now() < g_bt_tmo_until); +} +static void bt_circuit_note(runcmd_status_t st) +{ + if (st == RUNCMD_TIMEOUT) { + if (g_bt_tmo_streak < BT_TMO_STREAK_MAX) g_bt_tmo_streak++; + if (g_bt_tmo_streak >= BT_TMO_STREAK_MAX) g_bt_tmo_until = mono_now() + BT_TMO_COOLDOWN_SEC; + } else { + g_bt_tmo_streak = 0; + g_bt_tmo_until = 0; + } +} + +/* 写后真态复查:总预算 budget_ms,**每次探测只拿剩余预算**,一旦命中 want 立刻返回。 + * 返回最后一次探测值(1/0/-1=未知)。总耗时 ≤ budget_ms + 一次探测的收尾。 */ +static int bt_poll_probe(bt_probe_fn fn, const char *mac, int want, int budget_ms) +{ + long deadline = mono_ms() + budget_ms; + long remain = budget_ms; + int v = fn(mac, (int)(remain > 0 ? remain : 1)); + while (v != want) { + remain = deadline - mono_ms(); + if (remain <= 0) break; struct timespec ts = { 0, BT_VERIFY_GAP_MS * 1000000L }; nanosleep(&ts, NULL); - v = fn(mac); + remain = deadline - mono_ms(); + if (remain <= 0) break; + v = fn(mac, (int)remain); } return v; } -/* bt on/off:hciconfig + rfkill,然后按真实 power 态定稿(不再「下发即成功」)。 */ +/* bt on/off:顺序按 M47 finding 走 —— 开:先解 rfkill(全部节点)再 up(blocked 时 up 必败); + * 关:先 down 再 block。up 第一次没生效时在**剩余预算内**重试一次(射频刚放开常差一拍)。 */ static void bt_onoff(const char *cmd, ctl_result_t *r) { int up = strcmp(cmd, "on") == 0; + int want = up ? 1 : 0; + long deadline = mono_ms() + BT_ACT_SHORT_MS + BT_VERIFY_BUDGET_MS; char cmd2[192]; snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s hci0 %s", g_hciconfig, up ? "up" : "down"); char buf[256]; + buf[0] = 0; runcmd_res_t res; + memset(&res, 0, sizeof res); + res.exit_code = -1; + res.status = RUNCMD_EXEC; + + if (bt_circuit_open()) { + snprintf(r->text, sizeof r->text, + "bt %s: 熔断中(连续 %d 次命令超时,%ld 秒内不再下发;蓝牙栈可能卡住)", + cmd, g_bt_tmo_streak, g_bt_tmo_until - mono_now()); + r->code = MEOW_ERR_NODEV; + return; + } + if (up) rfkill_set_block_all("bluetooth", 0); /* 先解 block,否则 up 会被拦下 */ run_cmd_ex(buf, sizeof buf, BT_ACT_SHORT_MS, cmd2, &res); - rfkill_set_block("bluetooth", up ? 0 : 1); - int want = up ? 1 : 0; - int pw = -1; - long deadline = mono_ms() + BT_VERIFY_BUDGET_MS; + bt_circuit_note(res.status); + if (!up) rfkill_set_block_all("bluetooth", 1); /* 先 down 再 block */ + + int pw = -1, retried = 0; for (;;) { - pw = bt_powered_now(); - if (pw == want || mono_ms() >= deadline) break; + long remain = deadline - mono_ms(); + if (remain <= 0) break; + pw = bt_powered_now((int)remain); + if (pw == want) break; + if (up && !retried) { /* up 未生效:剩余预算内重试一次 */ + retried = 1; + remain = deadline - mono_ms(); + if (remain > 0) { + run_cmd_ex(buf, sizeof buf, (int)(remain > BT_ACT_SHORT_MS ? BT_ACT_SHORT_MS : remain), + cmd2, &res); + continue; + } + } + remain = deadline - mono_ms(); + if (remain <= 0) break; struct timespec ts = { 0, BT_VERIFY_GAP_MS * 1000000L }; nanosleep(&ts, NULL); } - char msg[240]; + char msg[280]; if (pw == want) { snprintf(msg, sizeof msg, "bt %s: hci0 %s + rfkill %s(power 已复查)", cmd, up ? "up" : "down", up ? "unblocked" : "blocked"); @@ -3442,19 +3588,29 @@ static void bt_onoff(const char *cmd, ctl_result_t *r) } /* connect/disconnect/pair/unpair:下发 + 按真态(hcitool con / bluetoothctl info)定稿。 - * 旧实现 `n >= 0 → MEOW_OK` 恒报成功(命令失败也返回 0 字节),这是「点配对提示成功但没配上」的根因。 */ + * 旧实现 `n >= 0 → MEOW_OK` 恒报成功(命令失败也返回 0 字节),这是「点配对提示成功但没配上」的根因。 + * 成功文案不再补设备名:那次 bluetoothctl 查询是 1-2 个额外子进程,会吃掉 1500ms 窗口; + * UI 本来就按 MAC join 名字(nm_parse_devices)。 */ static void bt_write_execute(const char *cmd, const char *arg, ctl_result_t *r) { int is_conn = !strcmp(cmd, "connect"), is_disc = !strcmp(cmd, "disconnect"); int is_pair = !strcmp(cmd, "pair"), is_unpair = !strcmp(cmd, "unpair"); const char *sub = is_unpair ? "remove" : cmd; /* bluetoothctl 取消配对=remove */ int tmo = (is_conn || is_pair) ? BT_ACT_TMO_MS : BT_ACT_SHORT_MS; + if (bt_circuit_open()) { /* 连续超时:不再 fork,如实回熔断 */ + snprintf(r->text, sizeof r->text, + "bt %s: 熔断中(连续 %d 次命令超时,%ld 秒内不再下发;蓝牙栈可能卡住)", + cmd, g_bt_tmo_streak, g_bt_tmo_until - mono_now()); + r->code = MEOW_ERR_NODEV; + return; + } char cmd2[224]; snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s %s %s", g_bluetoothctl, sub, arg); char out[512]; runcmd_res_t res; run_cmd_ex(out, sizeof out, tmo, cmd2, &res); + bt_circuit_note(res.status); int fail = 0; const char *why = run_cmd_why(&res, &fail); char snip[121]; @@ -3471,12 +3627,10 @@ static void bt_write_execute(const char *cmd, const char *arg, ctl_result_t *r) if (is_conn || is_disc) { int want = is_conn ? 1 : 0; - int v = bt_poll_probe(bt_probe_acl, arg, want); + int v = bt_poll_probe(bt_probe_acl, arg, want, BT_VERIFY_BUDGET_MS); if (v == want) { - char name[64] = ""; - if (is_conn) bt_name_for_mac(arg, name, sizeof name); - snprintf(msg, sizeof msg, "bt %s: %s %s%s%s(hcitool con 已复查)", cmd, - is_conn ? "已连接" : "已断开", arg, name[0] ? " " : "", name); + snprintf(msg, sizeof msg, "bt %s: %s %s(hcitool con 已复查)", cmd, + is_conn ? "已连接" : "已断开", arg); ctl_sets(r, MEOW_OK, msg); return; } @@ -3488,7 +3642,7 @@ static void bt_write_execute(const char *cmd, const char *arg, ctl_result_t *r) /* pair / unpair:以 bluetoothctl info 的 Paired: 字段为准 */ int want = is_pair ? 1 : 0; - int v = bt_poll_probe(bt_probe_paired, arg, want); + int v = bt_poll_probe(bt_probe_paired, arg, want, BT_VERIFY_BUDGET_MS); int ok = (v == want); if (!ok && is_unpair && v < 0 && !fail) ok = 1; /* remove 后设备整体消失(info 查不到)也算已解除 */ if (ok) { diff --git a/patch_v2/meow_netmgrd/test_bt_ctl.c b/patch_v2/meow_netmgrd/test_bt_ctl.c index 541df7a..c575e7f 100644 --- a/patch_v2/meow_netmgrd/test_bt_ctl.c +++ b/patch_v2/meow_netmgrd/test_bt_ctl.c @@ -54,17 +54,23 @@ int main(void) const char *T = Tbuf; char p[256]; char cmd[512]; + /* 可重复运行(r1 评审 P2):上一轮遗留的 conn_ / paired_ 状态文件会把「无连接」断言推翻。 + * 每次开跑先整棵删掉重来,退出时也清(见文件末尾)。 */ + { char rm[320]; snprintf(rm, sizeof rm, "rm -rf '%s'", Tbuf); (void)!system(rm); } mkdir(Tbuf, 0755); /* 假 /sys/class/bluetooth/hci0(available/powered/address) */ { snprintf(p, sizeof p, "%s/bt", T); mkdir(p, 0755); snprintf(p, sizeof p, "%s/bt/hci0", T); mkdir(p, 0755); snprintf(p, sizeof p, "%s/bt/hci0/address", T); wf(p, "4c:bc:98:b0:98:db\n"); snprintf(p, sizeof p, "%s/bt/hci0/power", T); wf(p, "off\n"); } - /* 假 rfkill(bt on/off 会写 soft) */ + /* 假 rfkill:真机有两个 bluetooth 节点(rfkill0/rfkill3),任一仍 block 就等于没开 */ { snprintf(p, sizeof p, "%s/rfkill", T); mkdir(p, 0755); - snprintf(p, sizeof p, "%s/rfkill/rfkill1", T); mkdir(p, 0755); - snprintf(p, sizeof p, "%s/rfkill/rfkill1/type", T); wf(p, "bluetooth\n"); - snprintf(p, sizeof p, "%s/rfkill/rfkill1/soft", T); wf(p, "1\n"); } + for (int i = 0; i < 2; i++) { + char d[300]; + snprintf(d, sizeof d, "%s/rfkill/rfkill%d", T, i ? 3 : 0); mkdir(d, 0755); + snprintf(p, sizeof p, "%s/type", d); wf(p, "bluetooth\n"); + snprintf(p, sizeof p, "%s/soft", d); wf(p, "1\n"); + } } /* 有状态桩的状态目录 */ snprintf(p, sizeof p, "%s/state", T); mkdir(p, 0755); @@ -94,6 +100,7 @@ int main(void) " echo \" Connected: $cv\"\n" " exit 0;;\n" " connect)\n" + " case \"$2\" in CC:*) sleep 5;; esac\n" " case \"$2\" in AA:*) echo 'Failed to connect: org.bluez.Error.Failed br-connection-refused'; exit 1;; esac\n" " echo yes > \"$S/conn_$2\"; echo yes > \"$S/paired_$2\"\n" " echo 'Attempting to connect'; echo 'Connection successful'; exit 0;;\n" @@ -102,6 +109,7 @@ int main(void) " case \"$2\" in BB:*) echo 'Successful disconnected'; exit 0;; esac\n" " echo no > \"$S/conn_$2\"; echo 'Successful disconnected'; exit 0;;\n" " pair)\n" + " case \"$2\" in CC:*) sleep 5;; esac\n" " case \"$2\" in AA:*) echo 'Failed to pair: org.bluez.Error.AuthenticationFailed'; exit 1;; esac\n" " echo yes > \"$S/paired_$2\"; echo 'Pairing successful'; exit 0;;\n" " remove)\n" @@ -135,8 +143,15 @@ int main(void) snprintf(sh, sizeof sh, "#!/bin/sh\n" "S='%s'\n" + "if [ -f \"$S/state/hang_hciconfig\" ]; then sleep 5; fi\n" + "blocked() { for t in \"$S\"/rfkill/*; do\n" + " [ -f \"$t/type\" ] || continue\n" + " v=\"\"; IFS= read -r v < \"$t/type\"; [ \"$v\" = bluetooth ] || continue\n" + " b=0; IFS= read -r b < \"$t/soft\"; [ \"$b\" = 1 ] && return 0\n" + " done; return 1; }\n" "case \"$2\" in\n" - " up) echo on > \"$S/bt/hci0/power\"; echo 'hci0: Type: Primary Bus: UART'; echo ' UP RUNNING';;\n" + " up) if blocked; then echo 'hci0: Operation not possible due to RF-kill' >&2; exit 1; fi\n" + " echo on > \"$S/bt/hci0/power\"; echo 'hci0: Type: Primary Bus: UART'; echo ' UP RUNNING';;\n" " down) echo off > \"$S/bt/hci0/power\"; echo 'hci0: Type: Primary Bus: UART'; echo ' DOWN';;\n" " *) echo 'hci0: Type: Primary Bus: UART'\n" " v=off; if [ -f \"$S/bt/hci0/power\" ]; then IFS= read -r v < \"$S/bt/hci0/power\"; fi\n" @@ -156,26 +171,34 @@ int main(void) "case \"$1\" in\n" " list-pcms) catf \"$S/pcms.txt\"; exit 0;;\n" " info)\n" + " if [ -f \"$S/hang_info\" ]; then sleep 5; fi\n" " path=\"$2\"; found=0\n" " if [ -f \"$S/pcms.txt\" ]; then\n" " while IFS= read -r l || [ -n \"$l\" ]; do\n" " if [ \"$l\" = \"$path\" ]; then found=1; fi\n" " done < \"$S/pcms.txt\"\n" " fi\n" - " case \"$path\" in *33_DE_2E_21_AD_30*) ;; *) found=0;; esac\n" + " case \"$path\" in *dev_33_DE_2E_21_AD_30*) ;; *) found=0;; esac\n" " if [ \"$found\" != 1 ]; then\n" " echo \"bluealsa-cli: E: CMD \\\"info\\\": Couldn't get BlueALSA PCM: Object path not found: '$path'\" >&2\n" " exit 1\n" " fi\n" - " echo \"BlueALSA PCM: $path\"\n" - " echo ' Transport: A2DP-source'\n" - " echo ' Codec: SBC'\n" - " echo ' CodecConfiguration: 0x3f'\n" - " echo ' Sampling: 44100 Hz'\n" - " echo ' Channels: 2'\n" - " echo ' Bits per Sample: 16'\n" - " echo ' Running: true'\n" - " echo ' State: RUNNING'\n" + " case \"$path\" in *dev_33_DE_2E_21_AD_30*) ;; *) exit 1;; esac\n" + " if [ -f \"$S/running\" ]; then run=true; else run=false; fi\n" + " echo \"Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\"\n" /* ← 真机原文(M50 2026-11-11 18:26) */ + " echo 'Sequence: 0'\n" + " echo 'Transport: A2DP-source'\n" + " echo 'Mode: sink'\n" + " echo \"Running: $run\"\n" + " echo 'Format: S16_LE'\n" + " echo 'Channels: 2'\n" + " echo 'Sampling: 48000 Hz'\n" + " echo 'Available codecs: SBC AAC'\n" + " echo 'Selected codec: AAC'\n" + " echo 'Delay: 15.0 ms'\n" + " echo 'SoftVolume: false'\n" + " echo 'Volume: L: 127 R: 127'\n" + " echo 'Muted: L: false R: false'\n" " exit 0;;\n" "esac\n" "exit 0\n", T); @@ -327,20 +350,59 @@ int main(void) CHECK(bt_path_mac("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_3", mac, sizeof mac) == 0); CHECK(bt_path_mac("/org/bluealsa/hci0/nothing", mac, sizeof mac) == 0); + /* 真机原文(M50 2026-11-11 18:26 实测,idle 与 running 各一份),逐字照抄 */ + static const char *REAL_IDLE = + "Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\n" + "Sequence: 0\n" + "Transport: A2DP-source\n" + "Mode: sink\n" + "Running: false\n" + "Format: S16_LE\n" + "Channels: 2\n" + "Sampling: 48000 Hz\n" + "Available codecs: SBC AAC\n" + "Selected codec: AAC\n" + "Delay: 15.0 ms\n" + "SoftVolume: false\n" + "Volume: L: 127 R: 127\n" + "Muted: L: false R: false\n"; + static const char *REAL_RUNNING = + "Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\n" + "Sequence: 3\n" + "Transport: A2DP-source\n" + "Mode: sink\n" + "Running: true\n" + "Format: S16_LE\n" + "Channels: 2\n" + "Sampling: 48000 Hz\n" + "Available codecs: SBC AAC\n" + "Selected codec: AAC\n" + "Delay: 17.2 ms\n" + "SoftVolume: false\n" + "Volume: L: 127 R: 127\n" + "Muted: L: false R: false\n"; bt_pcm_info_t info; - bt_pcm_info_parse("BlueALSA PCM: /org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n" - " Transport: A2DP-source\n" + bt_pcm_info_parse(REAL_IDLE, &info); + CHECK(info.has == 1); + CHECK(strcmp(info.codec, "AAC") == 0); /* Selected codec,不是 Available 列表 */ + CHECK(strcmp(info.rate, "48000 Hz") == 0); + CHECK(strcmp(info.channels, "2") == 0); + CHECK(strcmp(info.bits, "16") == 0); /* 从 Format: S16_LE 推出来 */ + CHECK(info.running == 0); /* Running: false */ + bt_pcm_info_parse(REAL_RUNNING, &info); + CHECK(info.running == 1); /* Running: true 才代表真在传 */ + CHECK(strcmp(info.codec, "AAC") == 0); + CHECK(strcmp(info.bits, "16") == 0); + /* 兼容旧/其它形态(有人给的是 Codec:/Bits per Sample:/State: RUNNING) */ + bt_pcm_info_parse(" Transport: A2DP-source\n" " Codec: SBC\n" " CodecConfiguration: 0x3f\n" " Sampling: 44100 Hz\n" " Channels: 2\n" " Bits per Sample: 16\n" - " Running: true\n" " State: RUNNING\n", &info); - CHECK(info.has == 1); - CHECK(strcmp(info.codec, "SBC") == 0); /* 不被 CodecConfiguration 抢走 */ + CHECK(strcmp(info.codec, "SBC") == 0); /* 不被 CodecConfiguration 抢走 */ CHECK(strcmp(info.rate, "44100 Hz") == 0); - CHECK(strcmp(info.channels, "2") == 0); CHECK(strcmp(info.bits, "16") == 0); CHECK(info.running == 1); /* 只有 Format 一行(属性打包)时的兜底 */ @@ -399,13 +461,20 @@ int main(void) bt_ctl_execute("a2dp", NULL, &r); CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "A2DP 33:DE:2E:21:AD:30") != NULL); - CHECK(strstr(r.text, "codec=SBC") != NULL); - CHECK(strstr(r.text, "rate=44100 Hz") != NULL); + CHECK(strstr(r.text, "codec=AAC") != NULL); /* 真机 Selected codec */ + CHECK(strstr(r.text, "rate=48000 Hz") != NULL); CHECK(strstr(r.text, "channels=2") != NULL); CHECK(strstr(r.text, "bits=16") != NULL); - CHECK(strstr(r.text, "acquired=1") != NULL); + CHECK(strstr(r.text, "acquired=0") != NULL); /* Running: false(空闲,端点还在) */ CHECK(strstr(r.text, "role=src") != NULL); + { /* Running: true → acquired=1(权威布尔) */ + snprintf(p, sizeof p, "%s/state/running", T); wf(p, "1\n"); + bt_ctl_execute("a2dp", NULL, &r); + CHECK(strstr(r.text, "acquired=1") != NULL); + unlink(p); + } /* 列表里有端点但 info 查不到(刚断开)→ 跳过,不编造 */ + snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, "/org/bluealsa/hci0/dev_00_00_00_00_00_00/a2dpsrc\n"); bt_ctl_execute("a2dp", NULL, &r); CHECK(strcmp(r.text, "A2DP none\n") == 0); @@ -413,6 +482,47 @@ int main(void) bt_ctl_execute("a2dp", NULL, &r); CHECK(strcmp(r.text, "A2DP none\n") == 0); + printf("== A2DP 预算:端点全卡死时不许把守护拖住(r1 评审 P2-1) ==\n"); + { + char pcms[512]; + snprintf(pcms, sizeof pcms, + "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n" + "/org/bluealsa/hci0/dev_11_22_33_44_55_66/a2dpsrc/sink\n" + "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_FF/a2dpsrc/sink\n" + "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsnk/source\n"); + snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, pcms); + snprintf(p, sizeof p, "%s/state/hang_info", T); wf(p, "1\n"); + long t0 = mono_ms(); + bt_ctl_execute("a2dp", NULL, &r); + long dt = mono_ms() - t0; + printf(" budget-a2dp(4 端点全卡死) = %ld ms\n", dt); + CHECK(dt < 1200); /* 旧实现实测 6053ms */ + CHECK(r.code == MEOW_OK); + unlink(p); + snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, ""); + + /* status JSON 的 a2dp 摘要:无连接→0 次 fork;有连接→TTL 缓存(第二次几乎免费) */ + snprintf(p, sizeof p, "%s/state/conn_33:DE:2E:21:AD:30", T); wf(p, "yes\n"); + snprintf(p, sizeof p, "%s/state/pcms.txt", T); + wf(p, "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n"); + snprintf(p, sizeof p, "%s/state/hang_info", T); wf(p, "1\n"); + static char js[8192]; + g_bt_a2dp_cache_ms = 0; + long t1 = mono_ms(); + build_status_json(js, sizeof js); + long d1 = mono_ms() - t1; + long t2 = mono_ms(); + build_status_json(js, sizeof js); + long d2 = mono_ms() - t2; + printf(" budget-status-json 1st = %ld ms, 2nd(cache) = %ld ms\n", d1, d2); + CHECK(d1 < 700); /* 单次刷新受 BT_A2DP_STATUS_BUDGET_MS 约束 */ + CHECK(d2 < 60); /* 缓存命中:不再 fork */ + CHECK(strstr(js, "\"a2dp_codec\"") != NULL); /* 字段保留(MeowPanel btCard 依赖) */ + unlink(p); + unlink(snprintf(p, sizeof p, "%s/state/conn_33:DE:2E:21:AD:30", T) ? p : p); + snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, ""); + } + printf("== 扫描缓存 → bt devices(真机 ANSI 字节 + devices 尾块 + 去重) ==\n"); { char cache[4096]; @@ -456,27 +566,105 @@ int main(void) } /* ================= 7. 写级:on/off + connect/disconnect/pair/unpair ================= */ - printf("== bt on/off:按 power 真态定稿 ==\n"); + printf("== bt on/off:按 power 真态定稿 + rfkill 顺序(先 unblock 再 up / 先 down 再 block) ==\n"); + /* 初始两个 bluetooth rfkill 节点 soft=1(真机形态)。旧实现在这里必败: + * 先 hciconfig up 被 rfkill 拦下,再 unblock 也不会重试 → 报「未生效」(假失败)。 */ bt_ctl_execute("on", NULL, &r); CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "已复查") != NULL); - CHECK(bt_powered_now() == 1); + CHECK(bt_powered_now(1500) == 1); + { /* 两个节点都必须被解 block(只写首个会让另一个仍 block) */ + int all_unblocked = 1; + for (int i = 0; i < 2; i++) { + char f[300], v[8] = "1"; + snprintf(f, sizeof f, "%s/rfkill/rfkill%d/soft", T, i ? 3 : 0); + FILE *fp = fopen(f, "r"); + if (fp) { if (fscanf(fp, "%7s", v) != 1) v[0] = 0; fclose(fp); } + if (strcmp(v, "0") != 0) all_unblocked = 0; + } + CHECK(all_unblocked == 1); + } bt_ctl_execute("off", NULL, &r); CHECK(r.code == MEOW_OK); - CHECK(bt_powered_now() == 0); + CHECK(bt_powered_now(1500) == 0); + { /* 关也要写全部节点 */ + int all_blocked = 1; + for (int i = 0; i < 2; i++) { + char f[300], v[8] = ""; + snprintf(f, sizeof f, "%s/rfkill/rfkill%d/soft", T, i ? 3 : 0); + FILE *fp = fopen(f, "r"); + if (fp) { if (fscanf(fp, "%7s", v) != 1) v[0] = 0; fclose(fp); } + if (strcmp(v, "1") != 0) all_blocked = 0; + } + CHECK(all_blocked == 1); + } + /* 反向验证:桩确实会在 blocked 时拒绝 up(否则上面的断言是空转) */ + { char f[300]; snprintf(f, sizeof f, "%s/rfkill/rfkill0/soft", T); wf(f, "1\n"); + snprintf(f, sizeof f, "%s/rfkill/rfkill3/soft", T); wf(f, "1\n"); + char out[128]; snprintf(out, sizeof out, "%s/hciconfig hci0 up 2>&1", sb_hc); + runcmd_res_t rr; run_cmd_ex(out, sizeof out, 800, out, &rr); + CHECK(rr.status != RUNCMD_OK); } + { char f[300]; snprintf(f, sizeof f, "%s/bt/hci0/power", T); wf(f, "off\n"); + for (int i = 0; i < 2; i++) { snprintf(f, sizeof f, "%s/rfkill/rfkill%d/soft", T, i ? 3 : 0); wf(f, "0\n"); } } + + printf("== 预算上界:命令卡死时写操作必须落在客户端 1500ms IPC 窗口内(r1 评审 P1) ==\n"); + { /* connect:bluetoothctl 卡住 5s(耳机没上电的真实形态)→ 总耗时必须 < 1450ms */ + long t0 = mono_ms(); + bt_ctl_execute("connect", "CC:CC:CC:CC:CC:CC", &r); + long dt = mono_ms() - t0; + printf(" budget-connect = %ld ms (code=%d)\n", dt, r.code); + CHECK(dt < 1450); + CHECK(r.code == MEOW_ERR_NODEV); /* 未连上就必须报未生效 */ + CHECK(strstr(r.text, "未生效") != NULL); + } + { long t0 = mono_ms(); + bt_ctl_execute("pair", "CC:CC:CC:CC:CC:CC", &r); + long dt = mono_ms() - t0; + printf(" budget-pair = %ld ms (code=%d)\n", dt, r.code); + CHECK(dt < 1450); + CHECK(r.code == MEOW_ERR_NODEV); + } + { /* bt on:hciconfig 卡住 5s → 同样必须 < 1450ms(旧实现实测 2424ms) */ + char f[300]; snprintf(f, sizeof f, "%s/state/hang_hciconfig", T); wf(f, "1\n"); + long t0 = mono_ms(); + bt_ctl_execute("on", NULL, &r); + long dt = mono_ms() - t0; + printf(" budget-bt-on = %ld ms (code=%d)\n", dt, r.code); + CHECK(dt < 1450); + CHECK(r.code == MEOW_ERR_NODEV); /* 卡住未生效:如实报 */ + unlink(f); + } + { /* 熔断:连续 3 次超时后,第 4 次必须**立刻**拒绝、不再 fork(防 55 个挂死 bluetoothctl + * 吃光进程槽的事故)。冷却 30s,测试里手工复位继续后续用例。 */ + CHECK(g_bt_tmo_streak >= BT_TMO_STREAK_MAX); + long t0 = mono_ms(); + bt_ctl_execute("connect", "CC:CC:CC:CC:CC:CC", &r); + long dt = mono_ms() - t0; + printf(" circuit-breaker 4th = %ld ms, code=%d\n", dt, r.code); + CHECK(dt < 100); /* 没再起子进程 */ + CHECK(r.code == MEOW_ERR_NODEV); + CHECK(strstr(r.text, "熔断中") != NULL); + g_bt_tmo_streak = 0; /* 测试专用复位(生产靠 30s 冷却+成功清零) */ + g_bt_tmo_until = 0; + } + { /* 恢复:正常 on,供后面的用例使用;同时验证「非超时返回会清零熔断计数」 */ + bt_ctl_execute("on", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(g_bt_tmo_streak == 0); + } printf("== bt connect:成功路径以 hcitool con 复查 ==\n"); bt_ctl_execute("connect", "33:DE:2E:21:AD:30", &r); CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "已连接") != NULL); CHECK(strstr(r.text, "hcitool con") != NULL); - CHECK(bt_acl_has("33:DE:2E:21:AD:30") == 1); + CHECK(bt_acl_has_tri("33:DE:2E:21:AD:30", 1500) == 1); printf("== bt disconnect:成功路径以 hcitool con 复查 ==\n"); bt_ctl_execute("disconnect", "33:DE:2E:21:AD:30", &r); CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "已断开") != NULL); - CHECK(bt_acl_has("33:DE:2E:21:AD:30") == 0); + CHECK(bt_acl_has_tri("33:DE:2E:21:AD:30", 1500) == 0); printf("== bt connect 失败:bluetoothctl 报错 + 无 ACL → ERR_NODEV(旧实现恒报成功) ==\n"); bt_ctl_execute("connect", "AA:AA:AA:AA:AA:AA", &r); @@ -501,11 +689,11 @@ int main(void) bt_ctl_execute("pair", "11:22:33:44:55:66", &r); CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "已配对") != NULL); - CHECK(bt_info_flag("11:22:33:44:55:66", "Paired") == 1); + CHECK(bt_info_flag("11:22:33:44:55:66", "Paired", 1500) == 1); bt_ctl_execute("unpair", "11:22:33:44:55:66", &r); /* remove → info 查不到也算解除 */ CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "已解除配对") != NULL); - CHECK(bt_info_flag("11:22:33:44:55:66", "Paired") == -1); + CHECK(bt_info_flag("11:22:33:44:55:66", "Paired", 1500) == -1); bt_ctl_execute("pair", "AA:AA:AA:AA:AA:AA", &r); CHECK(r.code == MEOW_ERR_NODEV); CHECK(strstr(r.text, "未生效") != NULL); @@ -529,6 +717,7 @@ int main(void) CHECK(bt_write_authorize(&other, 1, "status") == MEOW_OK); /* 读级任意 → 放 */ (void)cmd; + { char rm[320]; snprintf(rm, sizeof rm, "rm -rf '%s'", T); (void)!system(rm); } /* 退出即清,保证可重复 */ if (g_fail) { printf("\nBT_CTL TEST: %d FAIL\n", g_fail); return 1; } printf("\nBT_CTL TEST: ALL PASS\n"); return 0; diff --git a/patch_v2/meow_netmgrd/test_netmgrd.sh b/patch_v2/meow_netmgrd/test_netmgrd.sh index 19d9976..4e4c581 100755 --- a/patch_v2/meow_netmgrd/test_netmgrd.sh +++ b/patch_v2/meow_netmgrd/test_netmgrd.sh @@ -104,16 +104,21 @@ case "${1:-}" in # /org/bluealsa/hci0/dev_XX/a2dpsrc/sink echo "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink";; info) - # 真实 info 是按 PCM 路径查属性;桩固定回同一组字段 - echo "BlueALSA PCM: ${2:-/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc}" - echo " Transport: A2DP-source" - echo " Codec: SBC" - echo " CodecConfiguration: 0x3f" - echo " Sampling: 44100 Hz" - echo " Channels: 2" - echo " Bits per Sample: 16" - echo " Running: true" - echo " State: RUNNING";; + # 真机 v4.1.1 原文(M50 2026-11-11 18:26 抓,PCM 被 audiod 撑住时);此处取「正在传音频」态 + echo "Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30" + echo "Sequence: 3" + echo "Transport: A2DP-source" + echo "Mode: sink" + echo "Running: true" + echo "Format: S16_LE" + echo "Channels: 2" + echo "Sampling: 48000 Hz" + echo "Available codecs: SBC AAC" + echo "Selected codec: AAC" + echo "Delay: 17.2 ms" + echo "SoftVolume: false" + echo "Volume: L: 127 R: 127" + echo "Muted: L: false R: false";; esac exit 0 EOF @@ -191,10 +196,10 @@ ck T5e2 "Device 11:22:33:44:55:66 Speaker" "$OUT" # v4.1.1 没有 `a2dp list` 子命令,桩只认 list-pcms/info;acquired 由 `Running: true` 推出。 OUT=$(env $ENVS $Q "$T/meow_net" bt a2dp 2>&1) ck T5f "A2DP 33:DE:2E:21:AD:30" "$OUT" -ck T5f2 "codec=SBC" "$OUT" -ck T5f3 "rate=44100 Hz" "$OUT" +ck T5f2 "codec=AAC" "$OUT" # 真机 Selected codec(不是 Available codecs 列表) +ck T5f3 "rate=48000 Hz" "$OUT" ck T5f4 "channels=2" "$OUT" -ck T5f5 "bits=16" "$OUT" +ck T5f5 "bits=16" "$OUT" # 从 Format: S16_LE 推出 ck T5f6 "acquired=1" "$OUT" ck T5f7 "role=src" "$OUT"