From 370c2901608f04714a84cc6b759d08918706bc50 Mon Sep 17 00:00:00 2001 From: worker-m49 Date: Wed, 11 Nov 2026 18:23:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(bt):=20M49=20=E8=93=9D=E7=89=99?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E9=87=8D=E5=BB=BA=20=E2=80=94=20pai?= =?UTF-8?q?red-devices/a2dp=20list-pcms+info/run=5Fcmd=20=E5=9B=9B?= =?UTF-8?q?=E6=80=81+=E5=86=99=E5=90=8E=E7=9C=9F=E6=80=81=E5=A4=8D?= =?UTF-8?q?=E6=9F=A5+=E6=89=AB=E6=8F=8F=20ANSI=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 0.8.49 现场 /usr/bin/meow_netmgrd 是 P1(eb07415) 之前的旧件:`bt status` 无 connected= 字段、`bt connected|devices|a2dp` 连子命令都不存在(被当成写级拒绝)、 `bt paired` 落到 `bluetoothctl paired` → "Invalid command in menu main"。上游只有 NETMGR_UNREACH 才回退直连,INVAL/PERM 直接返回 → 三个列表恒空。 本单修掉「部署新件后依然存在」的三个真 bug + 一个解析缺陷: 1. `bluetoothctl paired` → `paired-devices`(bt_status 计数、bt paired 子命令两处); `devices` 只用于补名,不再当配对权威。 2. A2DP:v4.1.1 没有 `a2dp list`。改为 `list-pcms` 取端点 → 每端点 `info `; MAC 只从 D-Bus 路径的 `dev_XX_..` 段解(真机路径带 `/a2dpsrc/source` 段, 按「最后一段取 token」会拿到 "source")。info 查不到就跳过,不编造。 3. `run_cmd` 四态(成功有输出/成功无输出/非 0 退出/超时/起不来)+ stderr 有界抓取 (≤256B):旧实现只回字节数、失败也返回 0 → on/off/connect/disconnect/pair 恒报成功。 写操作改为下发后复查真态(hcitool con / bluetoothctl info Paired: / hciconfig power),超时或未收敛如实报 MEOW_ERR_NODEV。兼容外壳 run_cmd() 契约不变。 4. 扫描缓存:实时 `[NEW] Device` 行以 ANSI ESC 开头(真机 od -c 证据),旧解析器只认 行首 → 实时行全丢。新增 bt_clean_ansi()/bt_scan_line_parse():剥 ESC/CSI/控制符、 去 `[bluetooth]# ` 提示符、只认 [NEW] 与裸 Device、排除 [CHG]/[DEL];bt_scan_on 改用 g_bluetoothctl(原来硬编码,桩对 scan 无效)。 解析器全部抽成纯函数并用真机原始字节做单测(test_bt_ctl.c 扩充:四态、ANSI 样本、 截断/空输出、list-pcms/info、hcitool con)。test_netmgrd.sh 桩改 list-pcms/info + 真机 ANSI 缓存样本;顺带修掉两处宿主可移植性问题(/tmp 不可写→$TMPDIR、桩脚本在 PATH=/bin:/usr/bin 下用了 cat/rm→改 shell 内建,test_wifi_connect 的 g_wpa_cli 悬垂 栈指针)。 按塔要求:本 commit 不含二进制、不改 fw_content.conf(netmgrd 产物由 M51 统一重编)。 build_netmgrd.sh 只做 ROOT/ZIGCC/OUT 环境变量化(旧硬编码 $HOME/Project/R3ProII), M51 复用;time32 接入仍归 M43。 --- patch_v2/meow_netmgrd/build_netmgrd.sh | 28 +- patch_v2/meow_netmgrd/meow_netmgrd.c | 939 +++++++++++++++++----- patch_v2/meow_netmgrd/test_bt_ctl.c | 521 ++++++++++-- patch_v2/meow_netmgrd/test_netmgrd.sh | 74 +- patch_v2/meow_netmgrd/test_wifi_connect.c | 17 +- 5 files changed, 1267 insertions(+), 312 deletions(-) diff --git a/patch_v2/meow_netmgrd/build_netmgrd.sh b/patch_v2/meow_netmgrd/build_netmgrd.sh index a15f8f7..b280963 100755 --- a/patch_v2/meow_netmgrd/build_netmgrd.sh +++ b/patch_v2/meow_netmgrd/build_netmgrd.sh @@ -9,28 +9,36 @@ # 用法: # ./build_netmgrd.sh # 交叉编译 → patch_v2/meow_netmgrd/meow_netmgrd(mipsel 静态) # ./build_netmgrd.sh host # 宿主编译 → patch_v2/meow_netmgrd/meow_netmgrd_host +# OUT=/path/to/out ./build_netmgrd.sh # 产物另存(M49 起:仓库里不提交二进制,验证产物放仓库外) set -e cd "$(dirname "$0")" -ROOT="$HOME/Project/R3ProII" -CC="$ROOT/.tools/zig-mipsel-cc" +# 仓库根按本脚本位置推导(/patch_v2/meow_netmgrd/),MEOW_ROOT 可覆盖。 +# 旧版硬编码 $HOME/Project/R3ProII —— 换 checkout 路径/用 worktree 时直接编不动。 +ROOT="${MEOW_ROOT:-$(cd ../.. && pwd)}" +# 交叉编译器:ZIGCC env 优先,其次 /.tools/,最后 $HOME/.tools/(.tools 常只在主 checkout 里) +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:-.}" +[ -d "$OUT" ] || mkdir -p "$OUT" if [ "$1" = "host" ]; then HOST_CC="${CC_HOST:-cc}" # -Wno-format-truncation:宿主 glibc 对固定缓冲 snprintf 的「安全截断」误报(musl 交叉不报)。 "$HOST_CC" -Os -Wall -Wextra -U_FORTIFY_SOURCE -Wno-format-truncation -I"$I_HDR" \ - meow_netmgrd.c -o meow_netmgrd_host + meow_netmgrd.c -o "$OUT/meow_netmgrd_host" echo "== host build ==" - ls -la meow_netmgrd_host - file meow_netmgrd_host + ls -la "$OUT/meow_netmgrd_host" + file "$OUT/meow_netmgrd_host" exit 0 fi # meow_time32.c:独立 o32 time32 取时/睡眠(netmgrd 不链 meow_ui.a);修 DNS TTL 基准 -# 与 nanosleep 退避。守护/CLI 同一二进制,一次构建两处安装。 +# 与 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 meow_netmgrd + meow_netmgrd.c ../sdk/meow_time32.c -o "$OUT/meow_netmgrd" echo "== meow_netmgrd (mipsel static) ==" -ls -la meow_netmgrd -md5sum meow_netmgrd -file meow_netmgrd +ls -la "$OUT/meow_netmgrd" +md5sum "$OUT/meow_netmgrd" +file "$OUT/meow_netmgrd" diff --git a/patch_v2/meow_netmgrd/meow_netmgrd.c b/patch_v2/meow_netmgrd/meow_netmgrd.c index 7f0d2d6..e7f4234 100644 --- a/patch_v2/meow_netmgrd/meow_netmgrd.c +++ b/patch_v2/meow_netmgrd/meow_netmgrd.c @@ -89,6 +89,14 @@ 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) */ #define IFACE_MAX 16 #define RFKILL_MAX 8 #define DNS_MAX 8 @@ -226,61 +234,184 @@ static void net_log(const char *lvl, const char *tag, const char *fmt, ...) fprintf(stderr, "[net] %s %s %s\n", lvl, tag, msg); } -/* ---------------- 有界 popen 收割(257 教训:命令 wedge 时无条件 waitpid 会拖死调用线程) ---------------- - * 非阻塞读子进程 stdout(poll 限时);读完后 WNOHANG 优雅等 300ms,不死 SIGKILL 再等 300ms; - * 仍不死(D 态)放弃收割(泄漏一子进程换线程不卡死,已记日志)。返回读到的字节数(-1=起不来)。 - * cmd 为完整 shell 行;buf 由调用方提供(静态/栈),不 malloc——热路径禁 malloc。 */ -static void reap_bounded(pid_t pid, const char *cmd) +#define RUNCMD_ERR_MAX 256 /* stderr 只留前 256B:失败文案要有料,但不能把 IPC 报文撑爆 */ + +/* run_cmd 四态(M49)。P1 之前 run_cmd 只返回「读到的字节数」,命令失败也返回 0, + * 调用方 `n >= 0 → MEOW_OK`,于是 on/off/connect/disconnect/pair 全部恒报成功。 */ +typedef enum { + RUNCMD_OK = 0, /* 退出 0,有输出 */ + RUNCMD_EMPTY = 1, /* 退出 0,无输出(成功但没内容,≠ 失败) */ + RUNCMD_EXIT = 2, /* 非 0 退出码(127 = 命令不存在 / execl 失败) */ + RUNCMD_TIMEOUT = 3, /* 限时内没收完或子进程不退出(已 SIGKILL) */ + RUNCMD_EXEC = 4, /* 本进程 fork/pipe 失败,或子进程被信号杀死(非正常退出) */ +} runcmd_status_t; + +typedef struct { + int status; /* runcmd_status_t */ + int bytes; /* stdout 字节数(不含尾部 NUL;截断时 = cap-1) */ + int exit_code; /* WEXITSTATUS;未正常退出 = -1 */ + int signal; /* 被信号杀死时的信号号,否则 0 */ + int truncated; /* stdout 超出 buf 容量:已排空但丢弃尾部 */ + char err[RUNCMD_ERR_MAX]; /* stderr 前 256B(失败时进错误文案) */ +} runcmd_res_t; + +static long mono_ms(void) { + struct timespec tp; + clock_gettime(CLOCK_MONOTONIC, &tp); + return tp.tv_sec * 1000L + tp.tv_nsec / 1000000L; +} + +/* ---------------- 有界 popen 收割(257 教训:命令 wedge 时无条件 waitpid 会拖死调用线程) ---------------- + * 非阻塞读子进程 stdout/stderr(poll 限时);读完后 WNOHANG 优雅等 300ms,不死 SIGKILL 再等 300ms; + * 仍不死(D 态)放弃收割(泄漏一子进程换线程不卡死,已记日志)。 + * 返回 1=已收割(*st 有值),0=放弃收割。 + * cmd 为完整 shell 行;buf 由调用方提供(静态/栈),不 malloc——热路径禁 malloc。 */ +static int reap_bounded(pid_t pid, const char *cmd, int *st) +{ + *st = 0; for (int i = 0; i < 30; i++) { - if (waitpid(pid, NULL, WNOHANG) == pid) return; + if (waitpid(pid, st, WNOHANG) == pid) return 1; struct timespec ts = { 0, 10 * 1000000L }; nanosleep(&ts, NULL); } svc_log("run_cmd: child %ld not exit in 300ms, SIGKILL (%s)", (long)pid, cmd); kill(pid, SIGKILL); for (int i = 0; i < 30; i++) { - if (waitpid(pid, NULL, WNOHANG) == pid) return; + if (waitpid(pid, st, WNOHANG) == pid) return 1; struct timespec ts = { 0, 10 * 1000000L }; nanosleep(&ts, NULL); } svc_log("run_cmd: child %ld unkillable (kernel D-state?) — abandon reap (%s)", (long)pid, cmd); + return 0; } -static int run_cmd(char *buf, int cap, int tmo_ms, const char *cmd) +/* 带四态结果的执行器(res 可为 NULL,等价于旧 run_cmd)。返回 stdout 字节数(-1=起不来)。 + * stdout 超容量时继续排空(丢弃),保证子进程不被 EPIPE/SIGPIPE 提前打断、退出码可信。 */ +static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_res_t *res) { - int p[2]; - if (pipe(p) < 0) return -1; + runcmd_res_t lr; + runcmd_res_t *r = res ? res : &lr; + memset(r, 0, sizeof *r); + r->exit_code = -1; + r->status = RUNCMD_EXEC; + if (cap > 0) buf[0] = 0; + + int op[2], ep[2]; + if (pipe(op) < 0) return -1; + if (pipe(ep) < 0) { close(op[0]); close(op[1]); return -1; } pid_t pid = fork(); - if (pid < 0) { close(p[0]); close(p[1]); return -1; } + if (pid < 0) { close(op[0]); close(op[1]); close(ep[0]); close(ep[1]); return -1; } if (pid == 0) { - dup2(p[1], 1); - int dn = open("/dev/null", O_WRONLY); - if (dn >= 0) { dup2(dn, 2); close(dn); } - close(p[0]); close(p[1]); + dup2(op[1], 1); + dup2(ep[1], 2); /* M49:stderr 不再丢 /dev/null,失败时留一段进错误文案 */ + close(op[0]); close(op[1]); close(ep[0]); close(ep[1]); execl("/bin/sh", "sh", "-c", cmd, (char *)0); _exit(127); } - close(p[1]); - fcntl(p[0], F_SETFL, fcntl(p[0], F_GETFL, 0) | O_NONBLOCK); - int off = 0; - int remain = tmo_ms; - while (remain > 0 && off < cap - 1) { - struct pollfd pf = { p[0], POLLIN, 0 }; - int r = poll(&pf, 1, remain); - if (r < 0) { if (errno == EINTR) continue; break; } - if (r == 0) break; - ssize_t rd = read(p[0], buf + off, (size_t)(cap - 1 - off)); - if (rd == 0) break; - if (rd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) continue; break; } - off += (int)rd; + close(op[1]); close(ep[1]); + fcntl(op[0], F_SETFL, fcntl(op[0], F_GETFL, 0) | O_NONBLOCK); + fcntl(ep[0], F_SETFL, fcntl(ep[0], F_GETFL, 0) | O_NONBLOCK); + + int off = 0, eoff = 0, trunc = 0, out_eof = 0, err_eof = 0, timed_out = 0; + long deadline = mono_ms() + (tmo_ms > 0 ? tmo_ms : 1); + char stray[256]; + while (!(out_eof && err_eof)) { + long remain = deadline - mono_ms(); + if (remain <= 0) { timed_out = 1; break; } + struct pollfd pf[2]; + int nf = 0; + if (!out_eof) { pf[nf].fd = op[0]; pf[nf].events = POLLIN; pf[nf].revents = 0; nf++; } + if (!err_eof) { pf[nf].fd = ep[0]; pf[nf].events = POLLIN; pf[nf].revents = 0; nf++; } + int pr = poll(pf, (nfds_t)nf, (int)remain); + if (pr < 0) { if (errno == EINTR) continue; timed_out = 1; break; } + if (pr == 0) { timed_out = 1; break; } + for (int i = 0; i < nf; i++) { + if (!(pf[i].revents & (POLLIN | POLLHUP | POLLERR | POLLNVAL))) continue; + int is_out = (pf[i].fd == op[0]); + char *dst = is_out ? buf : r->err; + int dcap = is_out ? (cap > 0 ? cap : 1) : RUNCMD_ERR_MAX; + int *doff = is_out ? &off : &eoff; + int into = 1; + char *p = dst + *doff; + int room = dcap - 1 - *doff; + if (room <= 0) { p = stray; room = (int)sizeof stray; into = 0; } + ssize_t rd = read(pf[i].fd, p, (size_t)room); + if (rd == 0) { if (is_out) out_eof = 1; else err_eof = 1; continue; } + if (rd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) continue; + if (is_out) out_eof = 1; else err_eof = 1; + continue; + } + if (into) *doff += (int)rd; + else if (is_out) trunc = 1; + } } - close(p[0]); - buf[off] = 0; - reap_bounded(pid, cmd); + close(op[0]); + close(ep[0]); + if (cap > 0) buf[off] = 0; + r->err[eoff] = 0; + + if (timed_out) kill(pid, SIGKILL); /* 已经超时:不再多等 300ms */ + int st = 0; + int reaped = reap_bounded(pid, cmd, &st); + r->bytes = off; + r->truncated = trunc; + if (reaped) { + if (WIFEXITED(st)) r->exit_code = WEXITSTATUS(st); + else if (WIFSIGNALED(st)) r->signal = WTERMSIG(st); + } + if (timed_out || !reaped) r->status = RUNCMD_TIMEOUT; + else if (r->signal || r->exit_code < 0) r->status = RUNCMD_EXEC; + else if (r->exit_code != 0) r->status = RUNCMD_EXIT; + else if (off > 0) r->status = RUNCMD_OK; + else r->status = RUNCMD_EMPTY; return off; } +/* 兼容外壳:返回读到的字节数(-1=起不来),语义与 P1 之前一致(调用点无需改动)。 */ +static int run_cmd(char *buf, int cap, int tmo_ms, const char *cmd) +{ + return run_cmd_ex(buf, cap, tmo_ms, cmd, NULL); +} + +/* 失败时的一句话原因(有界;stderr 有料就带上,最多 120B,避免报文过长)。 */ +static const char *run_cmd_why(const runcmd_res_t *r, int *is_fail) +{ + static char why[160]; + why[0] = 0; + if (is_fail) *is_fail = 0; + switch (r->status) { + case RUNCMD_OK: case RUNCMD_EMPTY: return why; + case RUNCMD_TIMEOUT: snprintf(why, sizeof why, "超时"); break; + case RUNCMD_EXIT: + if (r->exit_code == 127) snprintf(why, sizeof why, "命令不存在(exit 127)"); + else snprintf(why, sizeof why, "exit %d", r->exit_code); + break; + case RUNCMD_EXEC: + if (r->signal) snprintf(why, sizeof why, "被信号 %d 终止", r->signal); + else snprintf(why, sizeof why, "无法执行"); + break; + default: snprintf(why, sizeof why, "未知失败"); break; + } + if (is_fail) *is_fail = 1; + if (r->err[0]) { + char one[121]; + int j = 0; + for (int i = 0; r->err[i] && j < (int)sizeof one - 1; i++) { + unsigned char c = (unsigned char)r->err[i]; + if (c == '\n' || c == '\r' || c == '\t') { if (j && one[j-1] != ' ') one[j++] = ' '; } + else if (c >= 0x20) one[j++] = (char)c; + } + one[j] = 0; + if (one[0]) { + size_t l = strlen(why); + snprintf(why + l, sizeof why - l, ": %s", one); + } + } + return why; +} + /* ---------------- JSON 构建(vsnprintf 助手法,截断分析免疫;同 powermgr) ---------------- */ typedef struct { char *buf; size_t cap, off; } jw_t; static void jadd(jw_t *w, const char *fmt, ...) @@ -613,6 +744,35 @@ 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) +{ + char p[192]; + snprintf(p, sizeof p, "%s/hci0/power", g_bt_dir); + char pw[8] = ""; + if (read_file_txt(p, pw, sizeof pw) > 0 && pw[0]) { + if (!strcmp(pw, "on")) return 1; + if (!strcmp(pw, "off")) return 0; + } + 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; + char *ln = buf; + while (ln && *ln) { + char *nl = strchr(ln, '\n'); + if (nl) *nl = 0; + char *q = ln; + while (*q == ' ' || *q == '\t') q++; + if (!strncmp(q, "UP", 2)) return 1; + if (!strncmp(q, "DOWN", 4)) return 0; + if (!nl) break; + ln = nl + 1; + } + return -1; +} + static int bt_acl_macs(char macs[][24], int max); /* 前向声明(bt_status 早于其定义) */ static void bt_status(bt_t *b) { @@ -623,29 +783,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; - /* powered:/sys/.../power(on/off)优先,回退 hciconfig 行首 UP/DOWN */ - snprintf(p, sizeof p, "%s/hci0/power", g_bt_dir); - char pw[8] = ""; - if (read_file_txt(p, pw, sizeof pw) > 0 && pw[0]) { - b->powered = !strcmp(pw, "on"); - } else { - 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) { - char *ln = buf; - while (ln && *ln) { - char *nl = strchr(ln, '\n'); - if (nl) *nl = 0; - char *q = ln; - while (*q == ' ' || *q == '\t') q++; - if (!strncmp(q, "UP", 2)) { b->powered = 1; break; } - if (!strncmp(q, "DOWN", 4)) { b->powered = 0; break; } - if (!nl) break; - ln = nl + 1; - } - } - } + { int pw = bt_powered_now(); 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); @@ -664,9 +802,11 @@ static void bt_status(bt_t *b) } } } - /* paired 数:bluetoothctl paired(BlueZ 5.54);失败/空 → -1。 */ + /* paired 数:bluetoothctl `paired-devices`(BlueZ 5.54 实测没有 `paired` 子命令, + * 旧实现 `paired` → "Invalid command in menu main"。`paired-devices` 才是已配对全量; + * `devices` 含未配对的历史缓存,只用于补名,不能当配对权威)。失败/空 → -1。 */ char cmd[160]; - snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s paired 2>/dev/null", g_bluetoothctl); + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s paired-devices 2>/dev/null", g_bluetoothctl); char buf[512]; if (run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd) > 0) { int n = 0; @@ -695,9 +835,15 @@ static void bt_status(bt_t *b) * - 已连接 = 内核 ACL(hcitool con,快速/权威/不受 bluetoothctl 过滤影响), * 名/配对来自 bluetoothctl `paired-devices`/`devices`(蓝虫子缓存)——不再依赖 * bluetoothctl `devices Connected`(慢 + 只反映 agent 缓存视图,易截断)。 - * - A2DP 详情 = bluealsa-cli a2dp list(codec/采样率/声道/位深/传输状态/角色)。 + * - A2DP 详情 = bluealsa-cli `list-pcms` → 每端点 `info `(v4.1.1 没有 `a2dp list`)。 * - 扫描 = netmgrd 统一生命周期(scan on 才扫、off/超时收尾;缓存 /tmp/meow_bt_scan.txt)。 * 输出均为文本(m_net_cmd_result_t.text),launcher 只做客户端解析,不再 bluetoothctl popen。 + * + * M49(2026-11-11)修掉 P1 遗留的三个「部署新件后依然存在」的真 bug: + * ① `bluetoothctl paired` 子命令不存在(BlueZ 5.54 → `paired-devices`); + * ② `bluealsa-cli a2dp list` 子命令不存在(v4.1.1 → `list-pcms` + `info`); + * ③ run_cmd 只回字节数、丢 stderr → 写操作恒报成功(改为四态 + 写后真态复查)。 + * 另修:扫描缓存的实时 `[NEW] Device` 行以 ANSI ESC 开头,旧解析器只认行首 → 实时行全丢。 * ================================================================ */ static int bt_hex(int c) { @@ -714,20 +860,288 @@ static int bt_mac_eq(const char *a, const char *b) } return 1; } -/* 在行内找第一个形如 XX:XX:XX:XX:XX:XX 的 MAC,返其指针(否则 NULL)。 */ -static const char *bt_find_mac(const char *s) +/* ---------------- 纯解析函数(宿主单测直接调用;不做 IO) ---------------- + * 真机原始字节(`od -c /tmp/meow_bt_scan.txt`,2026-11-11 15:46): + * ESC [ 0 ; 9 4 m [bluetooth] ESC [ 0 m # \r ESC [ K [001 ESC [ 0 ; 9 2 m 002 N E W + * 001 ESC [ 0 m 002 ] D e v i c e 7 9 : 8 5 : ... 7 9 - 8 5 - ... + * 即:每行前缀是彩色提示符 + `\r\033[K`,事件标签 `[NEW]` 外层还套了 \001/\002 与控制序列, + * 而 `Device ` 根本不在行首 —— 只认行首的旧解析器把这些实时行全丢了。 */ + +/* 剥掉 ANSI 转义序列与终端控制字符,写 out(NUL 结尾;超长截断)。返回 out。 + * 处理:CSI(ESC [ ... 终止字节 0x40..0x7E,含 ESC[K / ESC[P / 颜色码)、其余 ESC+x 单字节、 + * \r \t 等 C0 控制符、DEL;>=0x80 的字节原样保留(UTF-8 设备名)。 */ +static const char *bt_clean_ansi(const char *in, char *out, size_t cap) { - while (*s) { - int ok = 1; - for (int i = 0; i < 17; i++) { - int exp_colon = (i % 3 == 2); - if (exp_colon) { if (s[i] != ':') { ok = 0; break; } } - else if (bt_hex((unsigned char)s[i]) < 0) { ok = 0; break; } + size_t o = 0; + if (!out || cap == 0) return out; + for (const char *p = in; p && *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == 0x1b) { /* ESC */ + if (p[1] == '[') { /* CSI:跳到终止字节 */ + p += 2; + while (*p && !((unsigned char)*p >= 0x40 && (unsigned char)*p <= 0x7e)) p++; + if (!*p) break; + continue; + } + if (p[1]) p++; /* ESC + 单字节(ESC 7 / ESC ( 等) */ + continue; + } + if (c < 0x20 || c == 0x7f) continue; /* \r \t \001 \002 等一律丢弃 */ + if (o + 1 < cap) out[o++] = (char)c; + } + out[o] = 0; + return out; +} + +/* 解析 bluetoothctl 输出的一行 → "Device "。 + * 输入 raw 可以是任意原始行(含 ANSI/提示符/标签);成功返 1 并写 mac[≥18]/name。 + * 只认 `[NEW] Device `(发现事件)与裸 `Device `(`devices` 列表块); + * `[CHG]`/`[DEL]` 是属性变更/离开,不是「附近有这台」,返回 0。 + * name 允许为空(截断输出),MAC 不完整(行被截断)返回 0。 */ +static int bt_scan_line_parse(const char *raw, char *mac, size_t mcap, char *name, size_t ncap) +{ + if (mac && mcap) mac[0] = 0; + if (name && ncap) name[0] = 0; + if (!raw) return 0; + char clean[512]; + bt_clean_ansi(raw, clean, sizeof clean); + const char *p = clean; + /* 1) 提示符 "[bluetooth]# " 前缀(事件在它后面) */ + for (;;) { + const char *pr = strstr(p, "[bluetooth]"); + if (!pr) break; + const char *q = pr + 11; + while (*q == ' ' || *q == '\t') q++; + if (*q != '#') break; + q++; + while (*q == ' ' || *q == '\t') q++; + p = q; + } + /* 2) 事件标签:取行内最早出现的那个;只有 NEW 算「发现」 */ + size_t tag_off = 0; + int tag_kind = -1; + static const char *const kinds[3] = { "[NEW]", "[CHG]", "[DEL]" }; + for (int i = 0; i < 3; i++) { + const char *t = strstr(p, kinds[i]); + if (t && (tag_kind < 0 || (size_t)(t - p) < tag_off)) { + tag_off = (size_t)(t - p); + tag_kind = i; + } + } + if (tag_kind > 0) return 0; + if (tag_kind == 0) p += tag_off + 5; + while (*p == ' ' || *p == '\t') p++; + if (strncmp(p, "Device ", 7) != 0) return 0; + p += 7; + /* 3) MAC:严格 17 字符 XX:XX:XX:XX:XX:XX,后面必须是空白或行尾 */ + for (int i = 0; i < 17; i++) { + if (i % 3 == 2) { if (p[i] != ':') return 0; } + else if (bt_hex((unsigned char)p[i]) < 0) return 0; + } + char sep = p[17]; + if (sep && sep != ' ' && sep != '\t') return 0; + if (mac && mcap > 17) { memcpy(mac, p, 17); mac[17] = 0; } + p += 17; + while (*p == ' ' || *p == '\t') p++; + size_t l = strlen(p); + while (l > 0 && (p[l - 1] == ' ' || p[l - 1] == '\t' || p[l - 1] == '\n' || p[l - 1] == '\r')) l--; + if (l && name && ncap) { + if (l > ncap - 1) l = ncap - 1; + memcpy(name, p, l); name[l] = 0; + } + return 1; +} + +/* 解析 `hcitool con` 输出 → ACL MAC 列表(返回数量 ≤max)。 + * 行样本:"\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER";只认 "< ACL" 行, + * MAC 不完整(截断)跳过;表头 "Connections:" 自然不匹配。 */ +static int bt_acl_parse(const char *raw, char macs[][24], int max) +{ + int n = 0; + if (!raw || max <= 0) return 0; + const char *line = raw; + while (line && *line && n < max) { + const char *nl = strchr(line, '\n'); + const char *e = nl ? nl : line + strlen(line); + const char *acl = NULL; + for (const char *q = line; q + 6 <= e; q++) { + if (!strncmp(q, "< ACL ", 6)) { acl = q + 6; break; } + } + if (acl) { + int ok = 1; + for (int i = 0; i < 17 && ok; i++) { + if (i % 3 == 2) { if (acl[i] != ':') ok = 0; } + else if (bt_hex((unsigned char)acl[i]) < 0) ok = 0; + } + if (ok && acl + 17 <= e) { + memcpy(macs[n], acl, 17); macs[n][17] = 0; n++; + } + } + line = nl ? nl + 1 : NULL; + } + return n; +} + +/* bluealsa D-Bus 路径里的设备 MAC:/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc + * → 33:DE:2E:21:AD:30。成功返 1(mac 至少 18B)。 */ +static int bt_path_mac(const char *path, char *mac, size_t mcap) +{ + if (!mac || mcap < 18) return 0; + mac[0] = 0; + const char *d = path ? strstr(path, "dev_") : NULL; + if (!d) return 0; + d += 4; + char out[18]; + int j = 0; + for (int g = 0; g < 6; g++) { + for (int k = 0; k < 2; k++) { + if (bt_hex((unsigned char)*d) < 0) return 0; + out[j++] = *d++; + } + if (g < 5) { + if (*d != '_') return 0; + d++; + out[j++] = ':'; + } + } + out[17] = 0; + memcpy(mac, out, 18); + return 1; +} + +/* 解析 `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) +{ + int n = 0; + if (!raw || max <= 0) return 0; + const char *line = raw; + while (line && *line && n < max) { + const char *nl = strchr(line, '\n'); + const char *e = nl ? nl : line + strlen(line); + 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"))) { + size_t l = (size_t)(e - p); + if (l > 79) l = 79; + memcpy(paths[n], p, l); paths[n][l] = 0; n++; + } + line = nl ? nl + 1 : NULL; + } + return n; +} + +typedef struct { + char codec[16], rate[16], channels[8], bits[8], state[24]; + int running; /* 1=在跑 0=停 -1=未知 */ + int has; /* 至少命中一个字段 → 这份 info 输出可用 */ +} bt_pcm_info_t; + +/* 取 "Key: value" 里 Key 之后的值(到行尾,去首尾空白)。键必须整词匹配(避免 + * CodecConfiguration 命中 Codec)。value 写 v(可空)。返回 1=命中。 */ +static int bt_kv(const char *line, const char *key, char *v, size_t vcap) +{ + size_t kl = strlen(key); + if (v && vcap) v[0] = 0; + if (strncmp(line, key, kl) != 0) return 0; + const char *p = line + kl; + /* 整词判定:键后面必须是分隔符或行尾(避免 "Codec" 命中 "CodecConfiguration") */ + if (*p && *p != ':' && *p != '=' && *p != ' ' && *p != '\t') return 0; + while (*p == ' ' || *p == '\t') p++; + if (*p == ':' || *p == '=') { p++; while (*p == ' ' || *p == '\t') p++; } + if (v && vcap) { + size_t l = strlen(p); + while (l > 0 && (p[l - 1] == ' ' || p[l - 1] == '\t' || p[l - 1] == '\r' || p[l - 1] == '\n')) l--; + if (l > vcap - 1) l = vcap - 1; + memcpy(v, p, l); v[l] = 0; + } + return 1; +} + +/* 解析 `bluealsa-cli info ` 输出。字段名按 BlueALSA PCM 属性(Codec / Sampling / + * Channels / Bits per Sample / State / Running / Transport);同时兼容 `Format:` 一行里 + * 打包的 " Hz, channels, bits"。纯函数。 */ +static void bt_pcm_info_parse(const char *raw, bt_pcm_info_t *o) +{ + memset(o, 0, sizeof *o); + o->running = -1; + if (!raw) return; + char fmt[64] = ""; + const char *line = raw; + while (line && *line) { + const char *nl = strchr(line, '\n'); + const char *e = nl ? nl : line + strlen(line); + char lbuf[256]; + size_t l = (size_t)(e - line); + if (l > sizeof lbuf - 1) l = sizeof lbuf - 1; + memcpy(lbuf, line, l); lbuf[l] = 0; + char clean[256]; + bt_clean_ansi(lbuf, clean, sizeof clean); + char *p = clean; + while (*p == ' ' || *p == '\t') p++; + char v[64]; + if (!o->codec[0] && 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; + } else if (!o->channels[0] && bt_kv(p, "Channels", v, sizeof v) && v[0]) { + snprintf(o->channels, sizeof o->channels, "%s", v); o->has = 1; + } else if (!o->bits[0] && + (bt_kv(p, "Bits per Sample", v, sizeof v) || bt_kv(p, "Bits", v, sizeof v) || + bt_kv(p, "Bit depth", v, sizeof v)) && v[0]) { + 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, "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; + else if (strstr(v, "IDLE") || strstr(v, "STOP") || strstr(v, "SETUP")) o->running = 0; + } else if (bt_kv(p, "Running", v, sizeof v) && v[0]) { + o->has = 1; + if (!strcmp(v, "true") || !strcmp(v, "yes")) o->running = 1; + else if (!strcmp(v, "false") || !strcmp(v, "no")) o->running = 0; + } else if (bt_kv(p, "Transport", v, sizeof v) && v[0]) { + o->has = 1; + } + line = nl ? nl + 1 : NULL; + } + /* Format 兜底:如 "44100 Hz, 2 channels, 16 bits" / "44100 Hz 2 channels 16 bits" */ + if (fmt[0]) { + char *q; + if (!o->rate[0] && (q = strstr(fmt, "Hz")) != NULL) { + char *b = q; + while (b > fmt && (b[-1] == ' ' || b[-1] == '\t')) b--; /* 跳过 "Hz" 前的空格 */ + char *num = b; + while (num > fmt && num[-1] >= '0' && num[-1] <= '9') num--; + if (num < b && b - num <= 6) { + size_t l = (size_t)(b - num); + if (l > sizeof o->rate - 1) l = sizeof o->rate - 1; + memcpy(o->rate, num, l); o->rate[l] = 0; + } + } + if (!o->channels[0] && (q = strstr(fmt, "channels")) != NULL) { + char *b = q; + while (b > fmt && (b[-1] == ' ')) b--; + char *num = b; + while (num > fmt && num[-1] >= '0' && num[-1] <= '9') num--; + if (num < b && b - num <= 3) { + size_t l = (size_t)(b - num); + memcpy(o->channels, num, l); o->channels[l] = 0; + } + } + if (!o->bits[0] && (q = strstr(fmt, "bits")) != NULL) { + char *b = q; + while (b > fmt && (b[-1] == ' ')) b--; + char *num = b; + while (num > fmt && num[-1] >= '0' && num[-1] <= '9') num--; + if (num < b && b - num <= 3) { + size_t l = (size_t)(b - num); + memcpy(o->bits, num, l); o->bits[l] = 0; + } } - if (ok) return s; - s++; } - return NULL; } /* 内核 ACL 连接 MAC(hcitool con)。返回数(≤max)。 */ @@ -737,15 +1151,36 @@ static int bt_acl_macs(char macs[][24], int max) 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; - int n = 0; - char *line = buf; - while (line && *line && n < max) { - char *nl = strchr(line, '\n'); if (nl) *nl = 0; - const char *m = bt_find_mac(line); - if (m) { memcpy(macs[n], m, 17); macs[n][17] = 0; n++; } - line = nl ? nl + 1 : NULL; - } - return n; + return bt_acl_parse(buf, macs, max); +} + +/* 指定 MAC 是否有内核 ACL(hcitool con)。1=有 0=没有。 */ +static int bt_acl_has(const char *mac) +{ + 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; + return 0; +} + +/* `bluetoothctl info ` 的布尔字段真态(Paired / Connected / Trusted): + * 1=yes 0=no -1=未知(设备不在 BlueZ 列表里 / 后端失败)。 */ +static int bt_info_flag(const char *mac, const char *key) +{ + 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; + char keyp[32]; + snprintf(keyp, sizeof keyp, "%s:", key); + const char *p = strstr(buf, keyp); + if (!p) return -1; + p += strlen(keyp); + while (*p == ' ' || *p == '\t') p++; + if (!strncmp(p, "yes", 3)) return 1; + if (!strncmp(p, "no", 2)) return 0; + return -1; } /* 设备名解析(bluetoothctl `paired-devices`)+`devices` 两次兜底;命中返 1。 */ @@ -762,20 +1197,15 @@ static int bt_name_for_mac(const char *mac, char *name, int nlen) char *line = buf; while (line && *line) { char *nl = strchr(line, '\n'); if (nl) *nl = 0; - char *q = line; while (*q == ' ' || *q == '\t') q++; - if (!strncmp(q, "Device ", 7)) { - q += 7; - char *m = q; while (*m && *m != ' ' && *m != '\t' && *m != '\n' && *m != '\r') m++; - int ml = (int)(m - q); - if (ml == 17 && bt_mac_eq(q, mac)) { - char *nm = m; while (*nm == ' ' || *nm == '\t') nm++; - char *e = nm; while (*e && *e != '\n' && *e != '\r') e++; - *e = 0; - int l = (int)(e - nm); if (l > nlen - 1) l = nlen - 1; - if (l > 0) memcpy(name, nm, (size_t)l), name[l] = 0; - else { memcpy(name, mac, 17); name[17] = 0; } - return 1; + char m[24], nm[64]; + if (bt_scan_line_parse(line, m, sizeof m, nm, sizeof nm) && bt_mac_eq(m, mac)) { + if (nm[0]) { + int l = (int)strlen(nm); if (l > nlen - 1) l = nlen - 1; + memcpy(name, nm, (size_t)l); name[l] = 0; + } else { + memcpy(name, mac, 17); name[17] = 0; } + return 1; } line = nl ? nl + 1 : NULL; } @@ -799,94 +1229,51 @@ static void bt_connected_text(char *buf, int cap) buf[off] = 0; } -/* A2DP 传输详情:bluealsa-cli a2dp list → 每端点一行 +/* A2DP 传输详情(M49 重写)。 + * v4.1.1 的 bluealsa-cli **没有** `a2dp list` 子命令(旧实现恒失败 → 恒 "A2DP none")。 + * 正确路径:`list-pcms` 拿端点路径 → 每个 a2dpsrc/a2dpsnk 端点 `info ` 取属性。 + * 输出每端点一行: * "A2DP codec=<..> rate=<..> channels= bits= acquired=<0|1> role=" - * 解析宽松:字段缺失留空/0;整串不可辨认时退回原文。 */ + * 前提(真机实测,塔 2026-11-11):端点路径形如 `/org/bluealsa/hci0/dev_XX_../a2dpsrc/sink`, + * 末端段是 PCM 模式而非 MAC,且**端点易失**——只有 client 打开 PCM 时 bluealsa 才暴露它, + * 断开后 list-pcms 立刻空、info 报 Object path not found。 + * 所以:本函数是「此刻快照」;没有端点 → "A2DP none"(= 当前无 A2DP 活动,不是错误)。 + * 字段缺失写 "-"(info 输出认得但字段名对不上时也照样出行,至少让 UI 看见端点在);info 查询 + * 失败(端点刚好消失)就跳过该端点;一个都没有 → "A2DP none"。 */ static void bt_a2dp_text(char *buf, int cap) { + if (cap <= 0) return; + buf[0] = 0; char cmd[176]; - snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s a2dp list 2>/dev/null", g_bluealsa_cli); - char raw[MEOW_IPC_MAX - 16]; - if (run_cmd(raw, sizeof raw, RUNCMD_TMO_MS, cmd) <= 0) { - snprintf(buf, (size_t)cap, "A2DP none\n"); - return; - } - /* 端点累积器 */ - char emac[24] = "", role[4] = "-"; - char codec[16] = "", rate[16] = "", chn[8] = "", bits[8] = ""; - int acquired = -1, has_mac = 0, wrote = 0, off = 0; - char *line = raw; - while (line && *line) { - char *nl = strchr(line, '\n'); if (nl) *nl = 0; - char *p = line; while (*p == ' ' || *p == '\t') p++; - if (*p == '/') { /* 新端点:/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc[:] */ - if (has_mac && wrote) { - int l = snprintf(buf + off, (size_t)(cap - off), - "A2DP %s codec=%s rate=%s channels=%s bits=%s acquired=%d role=%s\n", - emac, codec[0] ? codec : "-", rate[0] ? rate : "-", - chn[0] ? chn : "-", bits[0] ? bits : "-", - acquired >= 0 ? acquired : 0, role); - if (l > 0 && off + l < cap) off += l; - } - /* 重置并为新端点解析 MAC/role */ - emac[0] = role[0] = codec[0] = rate[0] = chn[0] = bits[0] = 0; - snprintf(role, sizeof role, "%s", "-"); - acquired = -1; has_mac = 0; wrote = 0; - const char *dv = strstr(p, "dev_"); - if (dv) { - dv += 4; - int j = 0; - const char *q = dv; - while (*q && j < 17) { - if (*q == '_') { if (j == 2 || j == 5 || j == 8 || j == 11 || j == 14) emac[j] = ':'; else break; } - else if (bt_hex((unsigned char)*q) >= 0) emac[j] = *q; - else break; - j++; q++; - } - emac[17] = 0; - has_mac = (j == 17); - if (strstr(p, "a2dpsrc")) snprintf(role, sizeof role, "%s", "src"); - else if (strstr(p, "a2dpsnk")) snprintf(role, sizeof role, "%s", "snk"); - } else has_mac = 0; - wrote = 1; - line = nl ? nl + 1 : NULL; - continue; - } - /* 字段行:Codec:/Sampling:/Channels:/Bits:/State:/Acquired: */ - { const char *v = strstr(p, "Codec:"); if (v && codec[0] == 0) { - v += 6; while (*v == ' ' || *v == '\t') v++; - { const char *e = v; while (*e && *e != '\n' && *e != '\r') e++; - while (e > v && (e[-1] == ' ' || e[-1] == '\t')) e--; - int l = (int)(e - v); if (l > 15) l = 15; memcpy(codec, v, (size_t)l); codec[l] = 0; } } } - { const char *v = strstr(p, "Sampling"); if (v && rate[0] == 0) { - v = strchr(v, ':'); if (v) { v++; while (*v == ' ' || *v == '\t') v++; - { const char *e = v; while (*e && *e != '\n' && *e != '\r') e++; - while (e > v && (e[-1] == ' ' || e[-1] == '\t')) e--; - int l = (int)(e - v); if (l > 15) l = 15; memcpy(rate, v, (size_t)l); rate[l] = 0; } } } } - { const char *v = strstr(p, "Channels:"); if (v && chn[0] == 0) { - v += 9; while (*v == ' ' || *v == '\t') v++; - { const char *e = v; while (*e && *e != '\n' && *e != '\r') e++; - while (e > v && (e[-1] == ' ' || e[-1] == '\t')) e--; - int l = (int)(e - v); if (l > 7) l = 7; memcpy(chn, v, (size_t)l); chn[l] = 0; } } } - { const char *v = strstr(p, "Bits"); if (v && bits[0] == 0) { - v = strchr(v, ':'); if (v) { v++; while (*v == ' ' || *v == '\t') v++; - { const char *e = v; while (*e && *e != '\n' && *e != '\r') e++; - while (e > v && (e[-1] == ' ' || e[-1] == '\t')) e--; - int l = (int)(e - v); if (l > 7) l = 7; memcpy(bits, v, (size_t)l); bits[l] = 0; } } } } - if (strstr(p, "State:") || strstr(p, "State :") || strstr(p, "Acquired")) { - if (strstr(p, "RUNNING") || strstr(p, "ACTIVE") || strstr(p, "acquired") || strstr(p, "yes")) - acquired = 1; - } - line = nl ? nl + 1 : NULL; - } - /* 尾端点收尾 */ - if (has_mac && wrote) { + 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); + char paths[4][80]; + int np = bt_pcm_paths_parse(raw, paths, 4); + int off = 0; + for (int i = 0; i < np; i++) { + char mac[24] = "-"; + if (!bt_path_mac(paths[i], mac, sizeof mac)) continue; + const char *role = strstr(paths[i], "a2dpsnk") ? "snk" : "src"; + char cmd2[256]; + snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s info %s 2>&1", + 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; /* 端点消失/查询失败:跳过,不编造 */ + bt_pcm_info_t info; + bt_pcm_info_parse(ibuf, &info); + /* info 成功但字段名一个都没命中(版本差异)→ 仍出行,字段写 "-": + * 「端点存在」本身是真信息,报 "A2DP none" 会把在用的蓝牙音频说成「没有」。 */ int l = snprintf(buf + off, (size_t)(cap - off), "A2DP %s codec=%s rate=%s channels=%s bits=%s acquired=%d role=%s\n", - emac, codec[0] ? codec : "-", rate[0] ? rate : "-", - chn[0] ? chn : "-", bits[0] ? bits : "-", - acquired >= 0 ? acquired : 0, role); - if (l > 0 && off + l < cap) off += l; + mac, info.codec[0] ? info.codec : "-", info.rate[0] ? info.rate : "-", + info.channels[0] ? info.channels : "-", info.bits[0] ? info.bits : "-", + info.running > 0 ? 1 : 0, role); + if (l <= 0 || off + l >= cap) break; + off += l; } if (off == 0) snprintf(buf, (size_t)cap, "A2DP none\n"); else buf[off] = 0; @@ -915,30 +1302,26 @@ static void bt_a2dp_summary(char *codec, int clen, char *rate, int rlen) } } -/* 附近设备文本:netmgrd 权威扫描缓存(/tmp/meow_bt_scan.txt),行 "Device "(去重)。 */ +/* 附近设备文本:netmgrd 权威扫描缓存(/tmp/meow_bt_scan.txt),行 "Device "(去重)。 + * M49:实时 `[NEW] Device` 行以 ANSI ESC 开头(见 bt_scan_line_parse 注释里的字节证据), + * 旧实现只认行首 "Device "/"[NEW] Device " → 实时行全丢,只能靠末尾 `devices` 块。 */ static void bt_devices_text(char *buf, int cap) { int off = 0; + if (cap <= 0) return; + buf[0] = 0; FILE *f = fopen(g_bt_scan_cache, "r"); - if (!f) { buf[0] = 0; return; } + if (!f) return; char seen[16][24]; int n_seen = 0; - char line[256]; + char line[512]; while (fgets(line, sizeof line, f) && n_seen < 16) { - char *p = line; while (*p == ' ' || *p == '\t') p++; - const char *dv = NULL; - if (!strncmp(p, "Device ", 7)) dv = p + 7; - else if (!strncmp(p, "[NEW] Device ", 13)) dv = p + 13; - if (!dv) continue; - const char *mac = bt_find_mac(dv); - if (!mac) continue; + char mac[24], name[64]; + if (!bt_scan_line_parse(line, mac, sizeof mac, name, sizeof name)) continue; int dup = 0; for (int i = 0; i < n_seen; i++) if (bt_mac_eq(seen[i], mac)) { dup = 1; break; } if (dup) continue; - memcpy(seen[n_seen], mac, 17); seen[n_seen][17] = 0; n_seen++; - char *nm = (char *)(mac + 17); while (*nm == ' ' || *nm == '\t') nm++; - char *e = nm; while (*e && *e != '\n' && *e != '\r') e++; - *e = 0; - int l = snprintf(buf + off, (size_t)(cap - off), "Device %s %s\n", mac, nm); + memcpy(seen[n_seen], mac, 18); seen[n_seen][18] = 0; n_seen++; + int l = snprintf(buf + off, (size_t)(cap - off), "Device %s %s\n", mac, name); if (l <= 0 || off + l >= cap) break; off += l; } @@ -946,22 +1329,26 @@ static void bt_devices_text(char *buf, int cap) buf[off] = 0; } -/* 扫描生命周期(§七:进「附近查找」才 scan on;退页/超时 off)。默认 7s 节流。 */ +/* 扫描生命周期(§七:进「附近查找」才 scan on;退页/超时 off)。默认 7s 节流。 + * M49:改用 g_bluetoothctl(原来硬编码 "bluetoothctl",MEOW_BLUETOOTHCTL 桩对 scan 无效, + * 宿主测试没法覆盖扫描链路)。 */ static long g_bt_scan_ms = 0; static void bt_scan_on(void) { long t = mono_now(); if (g_bt_scan_ms && t - g_bt_scan_ms < 7000) return; g_bt_scan_ms = t; - char cmd[256]; + char cmd[320]; snprintf(cmd, sizeof cmd, "(sleep 1; echo 'scan on'; sleep 4; echo 'scan off'; echo 'devices'; echo 'quit') | " - "bluetoothctl > '%s' 2>&1 &", g_bt_scan_cache); - system(cmd); + "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s > '%s' 2>&1 &", g_bluetoothctl, g_bt_scan_cache); + if (system(cmd) != 0) svc_log("bt scan on: spawn failed (%s)", g_bt_scan_cache); } static void bt_scan_off(void) { - system("PATH=/bin:/sbin:/usr/bin:/usr/sbin bluetoothctl scan off 2>/dev/null"); + char cmd[192]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan off 2>/dev/null", g_bluetoothctl); + system(cmd); } /* ---------------- rfkill:统一软开关 ---------------- */ @@ -2976,17 +3363,143 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r) } } +/* 取命令输出的一段可读文本(去 ANSI/控制符,连续空白折成一个空格;最多 cap-1 字符)。 */ +static void bt_snip(const char *s, char *dst, size_t cap) +{ + if (!dst || cap == 0) return; + dst[0] = 0; + char tmp[512]; + bt_clean_ansi(s ? s : "", tmp, sizeof tmp); + size_t o = 0; + int prev_sp = 0; + for (size_t i = 0; tmp[i] && o + 1 < cap; i++) { + char c = tmp[i]; + if (c == ' ' || c == '\t') { + if (prev_sp) continue; + c = ' '; prev_sp = 1; + } else prev_sp = 0; + dst[o++] = c; + } + while (o && dst[o - 1] == ' ') o--; + 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"); } + +/* 写后真态复查:在 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) +{ + long deadline = mono_ms() + BT_VERIFY_BUDGET_MS; + int v = fn(mac); + while (v != want && mono_ms() < deadline) { + struct timespec ts = { 0, BT_VERIFY_GAP_MS * 1000000L }; + nanosleep(&ts, NULL); + v = fn(mac); + } + return v; +} + +/* bt on/off:hciconfig + rfkill,然后按真实 power 态定稿(不再「下发即成功」)。 */ static void bt_onoff(const char *cmd, ctl_result_t *r) { int up = strcmp(cmd, "on") == 0; - char cmd2[160]; - snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s hci0 %s 2>/dev/null", + char cmd2[192]; + snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s hci0 %s", g_hciconfig, up ? "up" : "down"); - char buf[128]; - int n = run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2); + char buf[256]; + runcmd_res_t res; + run_cmd_ex(buf, sizeof buf, BT_ACT_SHORT_MS, cmd2, &res); rfkill_set_block("bluetooth", up ? 0 : 1); - ctl_sets(r, n >= 0 ? MEOW_OK : MEOW_ERR_NODEV, - up ? "bt on: hci0 up + rfkill unblocked" : "bt off: hci0 down + rfkill blocked"); + int want = up ? 1 : 0; + int pw = -1; + long deadline = mono_ms() + BT_VERIFY_BUDGET_MS; + for (;;) { + pw = bt_powered_now(); + if (pw == want || mono_ms() >= deadline) break; + struct timespec ts = { 0, BT_VERIFY_GAP_MS * 1000000L }; + nanosleep(&ts, NULL); + } + char msg[240]; + if (pw == want) { + snprintf(msg, sizeof msg, "bt %s: hci0 %s + rfkill %s(power 已复查)", + cmd, up ? "up" : "down", up ? "unblocked" : "blocked"); + ctl_sets(r, MEOW_OK, msg); + return; + } + int fail = 0; + const char *why = run_cmd_why(&res, &fail); + char snip[121]; + bt_snip(buf, snip, sizeof snip); + snprintf(msg, sizeof msg, "bt %s: 未生效(powered=%s rfkill=%s%s%s%s%s)", cmd, + pw > 0 ? "on" : pw == 0 ? "off" : "unknown", + up ? "unblocked" : "blocked", + why[0] ? ";" : "", why, + (!why[0] && snip[0]) ? ";输出: " : "", (!why[0] && snip[0]) ? snip : ""); + ctl_sets(r, MEOW_ERR_NODEV, msg); +} + +/* connect/disconnect/pair/unpair:下发 + 按真态(hcitool con / bluetoothctl info)定稿。 + * 旧实现 `n >= 0 → MEOW_OK` 恒报成功(命令失败也返回 0 字节),这是「点配对提示成功但没配上」的根因。 */ +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; + 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); + int fail = 0; + const char *why = run_cmd_why(&res, &fail); + char snip[121]; + bt_snip(out, snip, sizeof snip); + char msg[300]; + + char tail[220]; + tail[0] = 0; + if (why[0]) snprintf(tail, sizeof tail, ";%s", why); + if (snip[0]) { + size_t l = strlen(tail); + if (l < sizeof tail - 1) snprintf(tail + l, sizeof tail - l, ";输出: %s", snip); + } + + if (is_conn || is_disc) { + int want = is_conn ? 1 : 0; + int v = bt_poll_probe(bt_probe_acl, arg, want); + 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); + ctl_sets(r, MEOW_OK, msg); + return; + } + snprintf(msg, sizeof msg, "bt %s: 未生效:%s(hcitool con %s%s)", cmd, arg, + is_conn ? "无该 ACL" : "仍有该 ACL", tail); + ctl_sets(r, MEOW_ERR_NODEV, msg); + return; + } + + /* pair / unpair:以 bluetoothctl info 的 Paired: 字段为准 */ + int want = is_pair ? 1 : 0; + int v = bt_poll_probe(bt_probe_paired, arg, want); + int ok = (v == want); + if (!ok && is_unpair && v < 0 && !fail) ok = 1; /* remove 后设备整体消失(info 查不到)也算已解除 */ + if (ok) { + snprintf(msg, sizeof msg, "bt %s: %s %s(bluetoothctl info Paired:%s 已复查)", cmd, + is_pair ? "已配对" : "已解除配对", arg, v > 0 ? "yes" : "no"); + ctl_sets(r, MEOW_OK, msg); + return; + } + snprintf(msg, sizeof msg, "bt %s: 未生效:%s(Paired=%s%s)", cmd, arg, + v > 0 ? "yes" : v == 0 ? "no" : "unknown", tail); + ctl_sets(r, MEOW_ERR_NODEV, msg); } static void bt_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r) @@ -3002,12 +3515,23 @@ static void bt_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r) } else if (!strcmp(cmd, "on") || !strcmp(cmd, "off")) { bt_onoff(cmd, r); } else if (!strcmp(cmd, "paired")) { - char cmd2[160]; - snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s paired 2>/dev/null", g_bluetoothctl); + char cmd2[176]; + snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s paired-devices", + g_bluetoothctl); char buf[MEOW_IPC_MAX - 16]; - int n = run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2); - if (n <= 0) ctl_sets(r, MEOW_ERR_NODEV, "bt paired: 无输出 (bluetoothctl 不可用/未开)"); - else ctl_sets(r, MEOW_OK, buf); + runcmd_res_t res; + run_cmd_ex(buf, sizeof buf, RUNCMD_TMO_MS, cmd2, &res); + if (res.status == RUNCMD_OK) { + ctl_sets(r, MEOW_OK, buf); + } else if (res.status == RUNCMD_EMPTY) { + ctl_sets(r, MEOW_OK, ""); /* 真态:一台都没配对,不是错误 */ + } else { + int fail = 0; + const char *why = run_cmd_why(&res, &fail); + char msg[224]; + snprintf(msg, sizeof msg, "bt paired: 后端失败(%s)", why); + ctl_sets(r, MEOW_ERR_NODEV, msg); + } } else if (!strcmp(cmd, "connected")) { char buf[MEOW_IPC_MAX - 16]; bt_connected_text(buf, sizeof buf); @@ -3023,14 +3547,7 @@ static void bt_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r) } else if (!strcmp(cmd, "connect") || !strcmp(cmd, "disconnect") || !strcmp(cmd, "pair") || !strcmp(cmd, "unpair")) { if (!arg || !arg[0]) { char m[48]; snprintf(m, sizeof m, "bt %s: 需 ", cmd); ctl_sets(r, MEOW_ERR_INVAL, m); return; } - const char *sub = (!strcmp(cmd, "unpair")) ? "remove" : cmd; /* bluetoothctl 取消配对=remove */ - char cmd2[192]; - snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s %s %s 2>/dev/null", - g_bluetoothctl, sub, arg); - char buf[256]; - int n = run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2); - if (n >= 0) ctl_sets(r, MEOW_OK, buf[0] ? buf : "bt cmd: 已下发"); - else ctl_sets(r, MEOW_ERR_NODEV, "bt cmd: 后端执行失败"); + bt_write_execute(cmd, arg, r); } else if (!strcmp(cmd, "scan")) { if (!arg || !strcmp(arg, "on")) { bt_scan_on(); ctl_sets(r, MEOW_OK, "bt scan on: 后台节流扫描已触发"); } else if (!strcmp(arg, "off")) { bt_scan_off(); ctl_sets(r, MEOW_OK, "bt scan off: 扫描已停"); } diff --git a/patch_v2/meow_netmgrd/test_bt_ctl.c b/patch_v2/meow_netmgrd/test_bt_ctl.c index 32a0983..541df7a 100644 --- a/patch_v2/meow_netmgrd/test_bt_ctl.c +++ b/patch_v2/meow_netmgrd/test_bt_ctl.c @@ -1,9 +1,13 @@ -/* test_bt_ctl.c — meow_netmgrd 蓝牙 P1 权威收口 纯逻辑宿主自测(本程序非生产代码)。 +/* test_bt_ctl.c — meow_netmgrd 蓝牙控制面 纯逻辑宿主自测(本程序非生产代码)。 * * 通过 #include 生产源文件(meow_netmgrd.c,用 #define main 改名避免入口冲突),同一 TU 直接访问 - * FILE-STATIC 函数/全局:bt_cmd_is_write / bt_write_authorize / bt_ctl_execute(connect/disconnect/ - * pair/unpair/connected/a2dp/devices/status)。工具经桩脚本(g_bluetoothctl/g_hcitool/g_bluealsa_cli/ - * g_bt_scan_cache 指向 $T/桩)覆盖,不依赖真实蓝牙栈。 + * FILE-STATIC 函数/全局:三个纯解析器(bt_scan_line_parse / bt_pcm_paths_parse+bt_pcm_info_parse / + * bt_acl_parse)、run_cmd_ex 四态、bt_cmd_is_write / bt_write_authorize、bt_ctl_execute + * (status/paired/connected/a2dp/devices/on/off/connect/disconnect/pair/unpair/scan)。 + * 后端工具经有状态桩脚本(g_bluetoothctl/g_hcitool/g_bluealsa_cli/g_hciconfig/g_bt_scan_cache) + * 覆盖,不依赖真实蓝牙栈。 + * + * 字节样本来源:2026-11-11 真机 `od -c /tmp/meow_bt_scan.txt`(0.8.49,BluetoothCtl 5.54 交互输出)。 * * 用法:cc -Wall -Wextra -I../meow_launcherd test_bt_ctl.c -o test_bt_ctl && ./test_bt_ctl */ @@ -27,46 +31,334 @@ static void wf(const char *p, const char *s) if (f) { fputs(s, f); fclose(f); } } +/* ---- 真机原始字节样本(含 ANSI ESC / \001\002 标记 / \r\033[K / 提示符) ---- */ +static const char *S_NEW_PUDDING = + "\033[0;94m[bluetooth]\033[0m# \r\033[K" + "[\001\033[0;92m\002NEW\001\033[0m\002] Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n"; +static const char *S_CHG_POWERED = + "\033[0;94m[bluetooth]\033[0m# \r\033[K" + "[\001\033[0;93m\002CHG\001\033[0m\002] Controller 4C:BC:98:B0:98:DB Powered: no\n"; +static const char *S_DEL_LINE = "[DEL] Device 00:11:22:33:44:55\n"; +static const char *S_PROMPT_DEVICES = "\033[0;94m[bluetooth]\033[0m# devices\n"; +static const char *S_PLAIN_BLOCK = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\n"; +static const char *S_TRUNC_MAC = + "\033[0;94m[bluetooth]\033[0m# \r\033[K[NEW] Device 33:DE:2E:21:A"; + int main(void) { - static const char *T = "/tmp/nmg_bt"; - char p[192]; - mkdir(T, 0755); + /* 宿主临时目录:Termux/Android 上 /tmp 不可写,优先 $TMPDIR(mktemp 同规则) */ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "/tmp"; + static char Tbuf[256]; + snprintf(Tbuf, sizeof Tbuf, "%s/nmg_bt", base); + const char *T = Tbuf; + char p[256]; + char cmd[512]; + 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, "00:1A:7D:DA:71:13\n"); - snprintf(p, sizeof p, "%s/bt/hci0/power", T); wf(p, "on\n"); } + 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) */ + { 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"); } + /* 有状态桩的状态目录 */ + snprintf(p, sizeof p, "%s/state", T); mkdir(p, 0755); - /* 桩:bluetoothctl(paired/devices/connect/remove)、hcitool con、bluealsa-cli a2dp、扫描缓存 */ - snprintf(p, sizeof p, "%s/bluetoothctl", T); wf(p, - "#!/bin/sh\ncase \"$1\" in\n" - " paired|paired-devices|devices) echo 'Device 33:DE:2E:21:AD:30 Pudding'; echo 'Device 11:22:33:44:55:66 Speaker';;\n" - " connect|disconnect|pair|remove) echo 'Controller 00:1A:7D:DA:71:13'; echo 'Device 33:DE:2E:21:AD:30 Connected: yes';;\n" - "esac\nexit 0\n"); chmod(p, 0755); - snprintf(p, sizeof p, "%s/hcitool", T); wf(p, - "#!/bin/sh\necho '\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER'\n" - "echo '\t< ACL 11:22:33:44:55:66 handle 13 state 1 lm SLAVE'\nexit 0\n"); chmod(p, 0755); - snprintf(p, sizeof p, "%s/bluealsa-cli", T); wf(p, - "#!/bin/sh\nif [ \"${1:-}\" = \"a2dp\" ]; then\n" - "echo '/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc:'\n" - "echo '\tCodec: SBC'\necho '\tSampling: 44100 Hz'\necho '\tChannels: 2'\n" - "echo '\tBits per Sample: 16'\necho '\tState: RUNNING'\necho '\tAcquired: yes'\nfi\nexit 0\n"); chmod(p, 0755); - snprintf(p, sizeof p, "%s/bt_scan.txt", T); wf(p, - "[NEW] Device 33:DE:2E:21:AD:30 Pudding\n[NEW] Device AA:BB:CC:DD:EE:FF Earbuds\n"); + /* 桩:bluetoothctl(paired-devices/devices/info/connect/disconnect/pair/remove,靠状态文件变迁) + * 注意:生产代码给后端命令前置 PATH=/bin:/sbin:/usr/bin:/usr/sbin,Termux 上 cat/basename/grep + * 都不在这几个目录里 —— 桩脚本只能用 shell 内建(read/echo/[ /参数展开),否则测试会假失败。 */ + snprintf(p, sizeof p, "%s/bluetoothctl", T); + { + char sh[3200]; + snprintf(sh, sizeof sh, + "#!/bin/sh\n" + "S='%s/state'\n" + "catf() { [ -f \"$1\" ] || return 0; while IFS= read -r l || [ -n \"$l\" ]; do echo \"$l\"; done < \"$1\"; }\n" + "case \"$1\" in\n" + " paired-devices) catf \"$S/paired_list.txt\"; exit 0;;\n" + " devices) catf \"$S/dev_list.txt\"; exit 0;;\n" + " info)\n" + " mac=\"$2\"\n" + " if [ ! -f \"$S/paired_$mac\" ]; then echo \"Device $mac not available\"; exit 1; fi\n" + " pv=no; IFS= read -r pv < \"$S/paired_$mac\"\n" + " if [ \"$pv\" = gone ]; then echo \"Device $mac not available\"; exit 1; fi\n" + " cv=no\n" + " if [ -f \"$S/conn_$mac\" ]; then IFS= read -r cv < \"$S/conn_$mac\"; fi\n" + " echo \"Device $mac\"\n" + " echo \" Name: Pudding\"\n" + " echo \" Paired: $pv\"\n" + " echo \" Connected: $cv\"\n" + " exit 0;;\n" + " connect)\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" + " disconnect)\n" + " case \"$2\" in AA:*) echo 'Failed to disconnect: not connected'; exit 1;; esac\n" + " 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 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" + " echo gone > \"$S/paired_$2\"; echo no > \"$S/conn_$2\"; echo 'Device has been removed'; exit 0;;\n" + "esac\n" + "exit 0\n", T); + wf(p, sh); chmod(p, 0755); + } + /* 桩:hcitool con —— 按 conn_=yes 标记生成 ACL 行 */ + snprintf(p, sizeof p, "%s/hcitool", T); + { + char sh[900]; + snprintf(sh, sizeof sh, + "#!/bin/sh\n" + "S='%s/state'\n" + "echo 'Connections:'\n" + "for f in \"$S\"/conn_*; do\n" + " [ -f \"$f\" ] || continue\n" + " v=no; IFS= read -r v < \"$f\"\n" + " [ \"$v\" = yes ] || continue\n" + " m=\"${f##*/}\"; m=\"${m#conn_}\"\n" + " echo \"\t< ACL $m handle 12 state 1 lm MASTER\"\n" + "done\n" + "exit 0\n", T); + wf(p, sh); chmod(p, 0755); + } + /* 桩:hciconfig —— `hci0 up/down` 改 sysfs power,`hci0` 打印状态 */ + snprintf(p, sizeof p, "%s/hciconfig", T); + { + char sh[900]; + snprintf(sh, sizeof sh, + "#!/bin/sh\n" + "S='%s'\n" + "case \"$2\" in\n" + " up) 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" + " if [ \"$v\" = on ]; then echo ' UP RUNNING'; else echo ' DOWN'; fi;;\n" + "esac\n" + "exit 0\n", T); + wf(p, sh); chmod(p, 0755); + } + /* 桩:bluealsa-cli —— list-pcms 端点由 $S/pcms.txt 控制,info 按路径返回属性 */ + snprintf(p, sizeof p, "%s/bluealsa-cli", T); + { + char sh[3200]; + snprintf(sh, sizeof sh, + "#!/bin/sh\n" + "S='%s/state'\n" + "catf() { [ -f \"$1\" ] || return 0; while IFS= read -r l || [ -n \"$l\" ]; do echo \"$l\"; done < \"$1\"; }\n" + "case \"$1\" in\n" + " list-pcms) catf \"$S/pcms.txt\"; exit 0;;\n" + " info)\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" + " 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" + " exit 0;;\n" + "esac\n" + "exit 0\n", T); + wf(p, sh); chmod(p, 0755); + } + /* 初始状态:两台已配对(真机 MOONDROP / 17OP15),无 ACL */ + snprintf(p, sizeof p, "%s/state/paired_list.txt", T); + wf(p, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice E0:42:6D:79:C5:98 17OP15\n"); + snprintf(p, sizeof p, "%s/state/dev_list.txt", T); + wf(p, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice E0:42:6D:79:C5:98 17OP15\n" + "Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n"); + snprintf(p, sizeof p, "%s/state/paired_33:DE:2E:21:AD:30", T); wf(p, "yes\n"); + snprintf(p, sizeof p, "%s/state/paired_E0:42:6D:79:C5:98", T); wf(p, "yes\n"); - g_bluetoothctl = ""; { char x[160]; snprintf(x, sizeof x, "%s/bluetoothctl", T); static char s[160]; - snprintf(s, sizeof s, "%s", x); g_bluetoothctl = s; } - g_hcitool = ""; { char x[160]; snprintf(x, sizeof x, "%s/hcitool", T); static char s[160]; - snprintf(s, sizeof s, "%s", x); g_hcitool = s; } - g_bluealsa_cli = ""; { char x[160]; snprintf(x, sizeof x, "%s/bluealsa-cli", T); static char s[160]; - snprintf(s, sizeof s, "%s", x); g_bluealsa_cli = s; } - g_bt_scan_cache = ""; { char x[160]; snprintf(x, sizeof x, "%s/bt_scan.txt", T); static char s[160]; - snprintf(s, sizeof s, "%s", x); g_bt_scan_cache = s; } + /* 指向桩(g_* 是 const char*,用静态缓冲承载) */ + static char sb_bt[256], sb_hci[256], sb_ba[256], sb_hc[256], sb_cache[256]; + snprintf(sb_bt, sizeof sb_bt, "%s/bluetoothctl", T); + snprintf(sb_hci, sizeof sb_hci, "%s/hcitool", T); + snprintf(sb_ba, sizeof sb_ba, "%s/bluealsa-cli", T); + snprintf(sb_hc, sizeof sb_hc, "%s/hciconfig", T); + snprintf(sb_cache, sizeof sb_cache, "%s/bt_scan.txt", T); + g_bluetoothctl = sb_bt; + g_hcitool = sb_hci; + g_bluealsa_cli = sb_ba; + g_hciconfig = sb_hc; + g_bt_scan_cache = sb_cache; g_bt_dir[0] = 0; snprintf(g_bt_dir, sizeof g_bt_dir, "%s/bt", T); + g_rfkill_dir[0] = 0; snprintf(g_rfkill_dir, sizeof g_rfkill_dir, "%s/rfkill", T); ctl_result_t r; + /* ================= 1. run_cmd_ex 四态(M49 新增) ================= */ + printf("== run_cmd_ex 四态:成功有输出/成功无输出/非 0 退出/超时/命令不存在/stderr 抓取 ==\n"); + { + runcmd_res_t rr; + char b[128]; + int n = run_cmd_ex(b, sizeof b, 1500, "echo hi", &rr); + CHECK(n == 3); + CHECK(rr.status == RUNCMD_OK); + CHECK(rr.exit_code == 0); + n = run_cmd_ex(b, sizeof b, 1500, "true", &rr); + CHECK(n == 0); + CHECK(rr.status == RUNCMD_EMPTY); /* 成功但无输出 ≠ 失败 */ + n = run_cmd_ex(b, sizeof b, 1500, "exit 3", &rr); + CHECK(n == 0); + CHECK(rr.status == RUNCMD_EXIT); + CHECK(rr.exit_code == 3); + n = run_cmd_ex(b, sizeof b, 1500, "nosuchcmd_m49_xyz", &rr); + CHECK(rr.status == RUNCMD_EXIT); + CHECK(rr.exit_code == 127); /* 命令不存在(sh 报 127) */ + n = run_cmd_ex(b, sizeof b, 150, "sleep 5", &rr); + CHECK(rr.status == RUNCMD_TIMEOUT); /* 限时内不退出 */ + CHECK(n >= 0); + n = run_cmd_ex(b, sizeof b, 1500, "echo boom >&2; exit 4", &rr); + CHECK(rr.status == RUNCMD_EXIT); + CHECK(rr.exit_code == 4); + CHECK(strstr(rr.err, "boom") != NULL); /* stderr 不再丢 /dev/null */ + int isfail = 0; + const char *why = run_cmd_why(&rr, &isfail); + CHECK(isfail == 1); + CHECK(strstr(why, "exit 4") != NULL); + CHECK(strstr(why, "boom") != NULL); + CHECK(run_cmd(b, sizeof b, 1500, "echo ok") == 3); /* 旧外壳契约不变 */ + } + + /* ================= 2. 扫描缓存解析(真机字节) ================= */ + printf("== bt_clean_ansi / bt_scan_line_parse(真机 od 字节样本) ==\n"); + { + char clean[512], mac[24], name[64]; + bt_clean_ansi(S_NEW_PUDDING, clean, sizeof clean); + CHECK(strstr(clean, "\033") == NULL); + CHECK(strstr(clean, "\001") == NULL && strstr(clean, "\002") == NULL); + CHECK(strstr(clean, "\r") == NULL); + CHECK(strcmp(clean, "[bluetooth]# [NEW] Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63") == 0); + + CHECK(bt_scan_line_parse(S_NEW_PUDDING, mac, sizeof mac, name, sizeof name) == 1); + CHECK(strcmp(mac, "79:85:D6:7B:73:63") == 0); + CHECK(strcmp(name, "79-85-D6-7B-73-63") == 0); + + CHECK(bt_scan_line_parse(S_PLAIN_BLOCK, mac, sizeof mac, name, sizeof name) == 1); + CHECK(strcmp(mac, "33:DE:2E:21:AD:30") == 0); + CHECK(strcmp(name, "MOONDROP Pudding") == 0); + + /* [CHG]/[DEL] 不是「附近有这台」 */ + CHECK(bt_scan_line_parse(S_CHG_POWERED, mac, sizeof mac, name, sizeof name) == 0); + CHECK(bt_scan_line_parse(S_DEL_LINE, mac, sizeof mac, name, sizeof name) == 0); + /* 提示符回显行 / 空行 / 截断到半截 MAC / 无关行 */ + CHECK(bt_scan_line_parse(S_PROMPT_DEVICES, mac, sizeof mac, name, sizeof name) == 0); + CHECK(bt_scan_line_parse("", mac, sizeof mac, name, sizeof name) == 0); + CHECK(bt_scan_line_parse(S_TRUNC_MAC, mac, sizeof mac, name, sizeof name) == 0); + CHECK(bt_scan_line_parse("Waiting to connect to bluetoothd...\r\n", mac, sizeof mac, name, sizeof name) == 0); + /* MAC 后无名字(截断输出)也要给出设备 */ + CHECK(bt_scan_line_parse("[NEW] Device AA:BB:CC:DD:EE:FF\n", mac, sizeof mac, name, sizeof name) == 1); + CHECK(strcmp(mac, "AA:BB:CC:DD:EE:FF") == 0); + CHECK(name[0] == 0); + } + + /* ================= 3. hcitool con 解析 ================= */ + printf("== bt_acl_parse(hcitool con 原始输出/空/截断) ==\n"); + { + char macs[8][24]; + CHECK(bt_acl_parse("Connections:\n", macs, 8) == 0); + CHECK(bt_acl_parse("", macs, 8) == 0); + int n = bt_acl_parse("Connections:\n" + "\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER\n" + "\t< ACL 11:22:33:44:55:66 handle 13 state 1 lm SLAVE\n", macs, 8); + CHECK(n == 2); + CHECK(strcmp(macs[0], "33:DE:2E:21:AD:30") == 0); + CHECK(strcmp(macs[1], "11:22:33:44:55:66") == 0); + /* SCO 行不是 ACL;截断 MAC 丢掉 */ + n = bt_acl_parse("\t< SCO 33:DE:2E:21:AD:30 handle 12 state 1\n" + "\t< ACL 33:DE:2E:21:A\n", macs, 8); + CHECK(n == 0); + } + + /* ================= 4. list-pcms / info 解析 ================= */ + printf("== bt_pcm_paths_parse / bt_path_mac / bt_pcm_info_parse ==\n"); + { + char paths[4][80]; + int n = bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n", paths, 4); + CHECK(n == 1); + CHECK(strcmp(paths[0], "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc") == 0); + n = bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n" + "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/a2dpsnk\r\n" + "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/sco\n", paths, 4); + CHECK(n == 2); /* SCO/HFP 端点不算 A2DP */ + CHECK(strcmp(paths[1], "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/a2dpsnk") == 0); + CHECK(bt_pcm_paths_parse("", paths, 4) == 0); + /* 真机 bluealsa v4.x 端点路径带末端段,且**结尾字面量不可依赖**: + * 塔 2026-11-11 首连实测是 `/a2dpsrc/sink`(不是 /source),M50 的旧 bt_dev_detect_mac() + * 按「最后一个 / 之后」取 token 就会拿到 "sink" 而不是 MAC。此处两种结尾都验。 */ + n = bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n" + "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/a2dpsnk/source\n", paths, 4); + CHECK(n == 2); + CHECK(strcmp(paths[0], "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink") == 0); + char mac2[24]; + CHECK(bt_path_mac(paths[0], mac2, sizeof mac2) == 1); + CHECK(strcmp(mac2, "33:DE:2E:21:AD:30") == 0); /* 不是 "sink" */ + CHECK(bt_path_mac(paths[1], mac2, sizeof mac2) == 1); + CHECK(strcmp(mac2, "E0:42:6D:79:C5:98") == 0); + char mac3[24]; /* 无末端段的老形态也兼容 */ + CHECK(bt_path_mac("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc", mac3, sizeof mac3) == 1); + CHECK(strcmp(mac3, "33:DE:2E:21:AD:30") == 0); + /* 截断输出(半截路径)也不许崩、不许当成端点 */ + bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_D", paths, 4); + char mac[24]; + CHECK(bt_path_mac("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc", mac, sizeof mac) == 1); + CHECK(strcmp(mac, "33:DE:2E:21:AD:30") == 0); + 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); + + 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" + " 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.rate, "44100 Hz") == 0); + CHECK(strcmp(info.channels, "2") == 0); + CHECK(strcmp(info.bits, "16") == 0); + CHECK(info.running == 1); + /* 只有 Format 一行(属性打包)时的兜底 */ + bt_pcm_info_parse(" Format: 48000 Hz, 2 channels, 24 bits\n", &info); + CHECK(info.has == 1); + CHECK(strcmp(info.rate, "48000") == 0); + CHECK(strcmp(info.channels, "2") == 0); + CHECK(strcmp(info.bits, "24") == 0); + CHECK(info.running == -1); + /* 空输出 / 错误输出(endpoint 不存在)→ has=0,不许编造 */ + bt_pcm_info_parse("", &info); + CHECK(info.has == 0); + bt_pcm_info_parse("bluealsa-cli: E: CMD \"info\": Couldn't get BlueALSA PCM: " + "Object path not found: '/org/bluealsa/hci0/dev_00/a2dpsrc'\n", &info); + CHECK(info.has == 0); + } + + /* ================= 5. 读写分类与授权 ================= */ printf("== bt_cmd_is_write 分类(读/写级) ==\n"); CHECK(bt_cmd_is_write("status") == 0); CHECK(bt_cmd_is_write("paired") == 0); @@ -81,46 +373,152 @@ int main(void) CHECK(bt_cmd_is_write("unpair") == 1); CHECK(bt_cmd_is_write("scan") == 1); - printf("== bt_ctl_execute 写级(connect/disconnect/pair/unpair,经桩 bluetoothctl) ==\n"); + /* ================= 6. 读级(status/paired/connected/a2dp/devices) ================= */ + printf("== 读级:status/paired/connected/a2dp/devices ==\n"); + bt_ctl_execute("status", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(strstr(r.text, "paired=2") != NULL); /* paired-devices 计数 */ + CHECK(strstr(r.text, "connected=0") != NULL); /* 内核 ACL 权威:当前无连接 */ + + bt_ctl_execute("paired", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding") != NULL); + CHECK(strstr(r.text, "Device E0:42:6D:79:C5:98 17OP15") != NULL); + + /* 没有已配对设备 = 空列表(不是错误) */ + snprintf(p, sizeof p, "%s/state/paired_list.txt", T); wf(p, ""); + bt_ctl_execute("paired", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(r.text[0] == 0); + snprintf(p, sizeof p, "%s/state/paired_list.txt", T); + wf(p, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice E0:42:6D:79:C5:98 17OP15\n"); + + /* a2dp:list-pcms → info,无端点时 "A2DP none" */ + snprintf(p, sizeof p, "%s/state/pcms.txt", T); + wf(p, "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n"); + 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, "channels=2") != NULL); + CHECK(strstr(r.text, "bits=16") != NULL); + CHECK(strstr(r.text, "acquired=1") != NULL); + CHECK(strstr(r.text, "role=src") != NULL); + /* 列表里有端点但 info 查不到(刚断开)→ 跳过,不编造 */ + 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); + wf(p, ""); + bt_ctl_execute("a2dp", NULL, &r); + CHECK(strcmp(r.text, "A2DP none\n") == 0); + + printf("== 扫描缓存 → bt devices(真机 ANSI 字节 + devices 尾块 + 去重) ==\n"); + { + char cache[4096]; + /* 结构与真机 /tmp/meow_bt_scan.txt 一致:开头等待行 + ESC[P + 提示符,实时 NEW 行, + * 末尾 devices 块,最后一行被截断(没有换行)。 */ + snprintf(cache, sizeof cache, + "Waiting to connect to bluetoothd...\r\033[P\033[P\033[P" + "\033[0;94m[bluetooth]\033[0m# \033[K\r\033[KAgent registered\n" + "\033[0;94m[bluetooth]\033[0m# scan on\n" + "%s" + "%s" + "\033[0;94m[bluetooth]\033[0m# scan off\n" + "\033[0;94m[bluetooth]\033[0m# devices\n" + "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\n" + "Device E0:42:6D:79:C5:98 17OP15\n" + "Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n" + "\033[0;94m[bluetooth]\033[0m# quit\n" + "\033[0;94m[bluetooth]\033[0m# \r\033[K" + "[\001\033[0;92m\002NEW\001\033[0m\002] Device AA:BB:CC:DD:EE:FF Earbuds", + S_NEW_PUDDING, S_CHG_POWERED); + snprintf(p, sizeof p, "%s/bt_scan.txt", T); wf(p, cache); + bt_ctl_execute("devices", NULL, &r); + CHECK(r.code == MEOW_OK); + /* 实时 NEW 行(ANSI 开头)现在能解析出来 */ + CHECK(strstr(r.text, "Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63") != NULL); + CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding") != NULL); + CHECK(strstr(r.text, "Device E0:42:6D:79:C5:98 17OP15") != NULL); + /* 截断的最后一行(无换行)也要收;[CHG] 行不许混进来 */ + CHECK(strstr(r.text, "Device AA:BB:CC:DD:EE:FF Earbuds") != NULL); + CHECK(strstr(r.text, "Controller 4C:BC:98") == NULL); + CHECK(strstr(r.text, "Powered") == NULL); + /* 去重:79:.. 实时行与 devices 块各一次,输出只一行 */ + { int cnt = 0; const char *q = r.text; + while ((q = strstr(q, "79:85:D6:7B:73:63")) != NULL) { cnt++; q += 17; } + CHECK(cnt == 1); } + /* 缓存不存在 → 空文本 */ + unlink(p); + bt_ctl_execute("devices", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(r.text[0] == 0); + } + + /* ================= 7. 写级:on/off + connect/disconnect/pair/unpair ================= */ + printf("== bt on/off:按 power 真态定稿 ==\n"); + bt_ctl_execute("on", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(strstr(r.text, "已复查") != NULL); + CHECK(bt_powered_now() == 1); + bt_ctl_execute("off", NULL, &r); + CHECK(r.code == MEOW_OK); + CHECK(bt_powered_now() == 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, "Connected") != NULL); + CHECK(strstr(r.text, "已连接") != NULL); + CHECK(strstr(r.text, "hcitool con") != NULL); + CHECK(bt_acl_has("33:DE:2E:21:AD:30") == 1); + + printf("== bt disconnect:成功路径以 hcitool con 复查 ==\n"); bt_ctl_execute("disconnect", "33:DE:2E:21:AD:30", &r); CHECK(r.code == MEOW_OK); - bt_ctl_execute("pair", "33:DE:2E:21:AD:30", &r); + CHECK(strstr(r.text, "已断开") != NULL); + CHECK(bt_acl_has("33:DE:2E:21:AD:30") == 0); + + printf("== bt connect 失败:bluetoothctl 报错 + 无 ACL → ERR_NODEV(旧实现恒报成功) ==\n"); + bt_ctl_execute("connect", "AA:AA:AA:AA:AA:AA", &r); + CHECK(r.code == MEOW_ERR_NODEV); + CHECK(strstr(r.text, "未生效") != NULL); + CHECK(strstr(r.text, "br-connection-refused") != NULL); /* 失败原因来自 stderr/stdout 片段 */ + + printf("== bt disconnect 假成功:命令 exit 0 但 ACL 仍在 → 仍报 ERR_NODEV ==\n"); + /* BB:* 的 disconnect 桩打印成功但不删 ACL(模拟「命令说成功、链路其实还在」)。 */ + snprintf(p, sizeof p, "%s/state/conn_BB:BB:BB:BB:BB:BB", T); wf(p, "yes\n"); + snprintf(p, sizeof p, "%s/state/paired_BB:BB:BB:BB:BB:BB", T); wf(p, "yes\n"); + bt_ctl_execute("disconnect", "BB:BB:BB:BB:BB:BB", &r); + CHECK(r.code == MEOW_ERR_NODEV); + CHECK(strstr(r.text, "仍有该 ACL") != NULL); + bt_ctl_execute("disconnect", "AA:AA:AA:AA:AA:AA", &r); /* 本来就没 ACL:真态=已断开 */ CHECK(r.code == MEOW_OK); - bt_ctl_execute("unpair", "33:DE:2E:21:AD:30", &r); + CHECK(strstr(r.text, "已断开") != NULL); + bt_ctl_execute("connect", "11:22:33:44:55:66", &r); CHECK(r.code == MEOW_OK); - CHECK(strstr(r.text, "Connected") != NULL); /* remove 桩回 Connected: yes */ + + printf("== bt pair/unpair:以 bluetoothctl info Paired: 复查 ==\n"); + 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); + 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); + bt_ctl_execute("pair", "AA:AA:AA:AA:AA:AA", &r); + CHECK(r.code == MEOW_ERR_NODEV); + CHECK(strstr(r.text, "未生效") != NULL); bt_ctl_execute("connect", "", &r); - CHECK(r.code == MEOW_ERR_INVAL); /* 缺 mac → INVAL */ + CHECK(r.code == MEOW_ERR_INVAL); /* 缺 mac → INVAL */ + + printf("== bt scan on/off(写级;桩 bluetoothctl 现在被 scan 真正调用) ==\n"); bt_ctl_execute("scan", NULL, &r); - CHECK(r.code == MEOW_OK); /* scan on(默认) */ + CHECK(r.code == MEOW_OK); CHECK(strstr(r.text, "scan on") != NULL); bt_ctl_execute("scan", "off", &r); CHECK(r.code == MEOW_OK); - printf("== bt_ctl_execute 读级(connected/a2dp/devices/status,权威) ==\n"); - bt_ctl_execute("connected", NULL, &r); - CHECK(r.code == MEOW_OK); - CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30") != NULL); - CHECK(strstr(r.text, "Pudding") != NULL); - bt_ctl_execute("a2dp", NULL, &r); - CHECK(r.code == MEOW_OK); - CHECK(strstr(r.text, "codec=SBC") != NULL); - CHECK(strstr(r.text, "rate=44100") != NULL); - CHECK(strstr(r.text, "acquired=1") != NULL); - CHECK(strstr(r.text, "role=src") != NULL); - snprintf(p, sizeof p, "%s/bt_scan.txt", T); wf(p, /* 重写(bt scan on 曾覆盖) */ - "[NEW] Device 33:DE:2E:21:AD:30 Pudding\n[NEW] Device AA:BB:CC:DD:EE:FF Earbuds\n"); - bt_ctl_execute("devices", NULL, &r); - CHECK(r.code == MEOW_OK); - CHECK(strstr(r.text, "Earbuds") != NULL); - CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30") != NULL); - bt_ctl_execute("status", NULL, &r); - CHECK(r.code == MEOW_OK); - CHECK(strstr(r.text, "connected=2") != NULL); - printf("== bt_write_authorize(系统身份 + MeowOS 才放行写级) ==\n"); caller_t other; memset(&other, 0, sizeof other); other.kind = CALL_OTHER; caller_t sys; memset(&sys, 0, sizeof sys); sys.kind = CALL_SYS; @@ -130,6 +528,7 @@ int main(void) CHECK(bt_write_authorize(&sys, 0, "connect") == MEOW_ERR_PERM); /* 系统+hiby → 拒 */ CHECK(bt_write_authorize(&other, 1, "status") == MEOW_OK); /* 读级任意 → 放 */ + (void)cmd; 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 afec0bd..19d9976 100755 --- a/patch_v2/meow_netmgrd/test_netmgrd.sh +++ b/patch_v2/meow_netmgrd/test_netmgrd.sh @@ -6,7 +6,7 @@ set -u cd "$(dirname "$0")" BIN="${1:-./meow_netmgrd_host}" Q="${QEMU:-}" -T=$(mktemp -d /tmp/netmgrdtest.XXXXXX) +T=$(mktemp -d "${TMPDIR:-/tmp}/netmgrdtest.XXXXXX") trap 'kill $DPID 2>/dev/null; rm -rf "$T"' EXIT PASS=0; FAIL=0 ck() { # ck <名> <期望子串> <实际> @@ -97,16 +97,24 @@ exit 0 EOF cat > "$T/bluealsa-cli" <<'EOF' #!/bin/sh -if [ "${1:-}" = "a2dp" ]; then -echo "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc:" -echo " Type: A2DP Source" -echo " Codec: SBC" -echo " Sampling: 44100 Hz" -echo " Channels: 2" -echo " Bits per Sample: 16" -echo " State: RUNNING" -echo " Acquired: yes" -fi +# v4.1.1 实际命令表:list-services/list-pcms/status/info/codec/volume/...(没有 a2dp 子命令) +case "${1:-}" in + list-pcms) + # 真机路径带末端段,且结尾字面量不可依赖(首连实测是 /a2dpsrc/sink): + # /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";; +esac exit 0 EOF cat > "$T/ip" <<'EOF' @@ -179,22 +187,32 @@ OUT=$(env $ENVS $Q "$T/meow_net" bt connected 2>&1) ck T5e "Device 33:DE:2E:21:AD:30 Pudding" "$OUT" ck T5e2 "Device 11:22:33:44:55:66 Speaker" "$OUT" -# ---- T5f: bt a2dp(P1:bluealsa-cli a2dp list → codec/rate/acquired/role)---- +# ---- T5f: bt a2dp(M49:bluealsa-cli list-pcms → info → codec/rate/acquired/role)---- +# v4.1.1 没有 `a2dp list` 子命令,桩只认 list-pcms/info;acquired 由 `Running: true` 推出。 OUT=$(env $ENVS $Q "$T/meow_net" bt a2dp 2>&1) -ck T5f "codec=SBC" "$OUT" -ck T5f2 "rate=44100" "$OUT" -ck T5f3 "acquired=1" "$OUT" -ck T5f4 "role=src" "$OUT" +ck T5f "A2DP 33:DE:2E:21:AD:30" "$OUT" +ck T5f2 "codec=SBC" "$OUT" +ck T5f3 "rate=44100 Hz" "$OUT" +ck T5f4 "channels=2" "$OUT" +ck T5f5 "bits=16" "$OUT" +ck T5f6 "acquired=1" "$OUT" +ck T5f7 "role=src" "$OUT" -# ---- T5g: bt devices(P1:netmgrd 权威扫描缓存)---- -cat > "$T/bt_scan.txt" <<'EOF' -[NEW] Device 33:DE:2E:21:AD:30 Pudding -[NEW] Device AA:BB:CC:DD:EE:FF Earbuds -[DEL] Device 00:11:22:33:44:55 -EOF +# ---- T5g: bt devices(P1:netmgrd 权威扫描缓存;M49:实时 [NEW] 行带 ANSI ESC 也要能解析)---- +# 真机 /tmp/meow_bt_scan.txt 的实时行以 ESC[0;94m[bluetooth]ESC[0m# \rESC[K[001 ESC[0;92m 002NEW 001 ESC[0m 002] 开头 +{ printf '%s\n' 'Waiting to connect to bluetoothd...' + printf '\033[0;94m[bluetooth]\033[0m# \r\033[K[\001\033[0;92m\002NEW\001\033[0m\002] Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n' + printf '\033[0;94m[bluetooth]\033[0m# scan off\n' + printf '\033[0;94m[bluetooth]\033[0m# devices\n' + printf 'Device AA:BB:CC:DD:EE:FF Earbuds\n' + printf '[DEL] Device 00:11:22:33:44:55\n' +} > "$T/bt_scan.txt" OUT=$(env $ENVS $Q "$T/meow_net" bt devices 2>&1) -ck T5g "Device 33:DE:2E:21:AD:30 Pudding" "$OUT" -ck T5g2 "Device AA:BB:CC:DD:EE:FF Earbuds" "$OUT" +ck T5g "Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63" "$OUT" # 实时 ANSI 行(旧实现全丢) +ck T5g2 "Device AA:BB:CC:DD:EE:FF Earbuds" "$OUT" # 尾部 devices 块 +if echo "$OUT" | grep -q "00:11:22:33:44:55"; then + FAIL=$((FAIL+1)); echo "FAIL T5g3 [DEL] 行被误当成附近设备: $OUT" +else PASS=$((PASS+1)); echo "PASS T5g3 [DEL] 不算附近设备"; fi # ---- T5h: bt 写级(connect/pair 等)在宿主 CLI=CALL_OTHER → 拒(仅系统身份+MeowOS 放行,见 T6)---- OUT=$(env $ENVS $Q "$T/meow_net" bt connect 33:DE:2E:21:AD:30 2>&1); RC=$? @@ -360,8 +378,13 @@ OUT=$(env $ENVS $Q "$T/meow_net" status 2>&1) ck T18d "rssi=-45" "$OUT" # ---- T14: DNS 代理 wire 实测(守护 B:高端口 + 桩上游 + 全局 deny;测 deny/forward/cache)---- +# 需要「非回环的本机地址」当上游:daemon 明确把 127.0.0.1 排除在上游之外(resolv_backup_apply +# 防环路,meow_netmgrd.c:2588)。宿主拿不到这样的地址(例如 Android/Termux 的 hostname 不支持 +# -I)时这套 wire 测试无法成立 —— 显式 SKIP,而不是留一堆假失败。 HOSTIP=$(hostname -I 2>/dev/null | awk '{print $1}') -[ -n "$HOSTIP" ] || HOSTIP="127.0.0.1" +if [ -z "$HOSTIP" ] || [ "$HOSTIP" = "127.0.0.1" ]; then + echo "SKIP T14 DNS wire(本机无 hostname -I 或只有回环地址;daemon 拒绝把 127.0.0.1 当上游以防环路)" +else cat > "$T/policy_dns" <<'EOF' global domain=-ads.example.com dns.filter_aaaa=1 EOF @@ -422,6 +445,7 @@ ck T14e "deny=1" "$OUT" kill $D2PID 2>/dev/null; wait $D2PID 2>/dev/null # 恢复守护 A 的 MEOW_RESOLV(其未被 DNS 触碰,此处确认) grep -q "nameserver 223.5.5.5" "$T/resolv" && { PASS=$((PASS+1)); echo "PASS T14f daemonA resolv untouched"; } || { FAIL=$((FAIL+1)); echo "FAIL T14f daemonA resolv touched"; } +fi # T14 上游能力守卫 # ---- T19: WiFi connect 状态机/持久化/隐藏网络/中文 SSID(宿主桩直驱 wifi_connect_ex)---- # daemon IPC 写级需系统身份(SO_PEERCRED /proc/exe),宿主 CLI=CALL_OTHER 必拒;故用 #include diff --git a/patch_v2/meow_netmgrd/test_wifi_connect.c b/patch_v2/meow_netmgrd/test_wifi_connect.c index effc32e..5ec6fa4 100644 --- a/patch_v2/meow_netmgrd/test_wifi_connect.c +++ b/patch_v2/meow_netmgrd/test_wifi_connect.c @@ -65,10 +65,12 @@ static void write_stub(void) fputs( "#!/bin/sh\n" "D=\"${MN_WIFI_DIR:-/tmp}\"\n" + "catf() { [ -f \"$1\" ] || return 0; while IFS= read -r l || [ -n \"$l\" ]; do echo \"$l\"; done < \"$1\"; }\n" "echo \"$@\" >> \"$D/log\"\n" "case \"$1\" in\n" " status)\n" - " m=$(cat \"$D/status_mode\" 2>/dev/null || echo completed)\n" + " m=completed\n" + " if [ -f \"$D/status_mode\" ]; then IFS= read -r m < \"$D/status_mode\"; fi\n" " if [ \"$m\" = \"associating\" ]; then\n" " echo \"wpa_state=ASSOCIATING\"; echo \"ssid=MyWiFi\"\n" " else\n" @@ -77,7 +79,7 @@ static void write_stub(void) " fi\n" " ;;\n" " list_networks)\n" - " if [ -f \"$D/list\" ]; then cat \"$D/list\"\n" + " if [ -f \"$D/list\" ]; then catf \"$D/list\"\n" " else printf 'network id / ssid / bssid / flags\\n0\\tMyWiFi\\tany\\t[CURRENT]\\n'\n" " fi\n" " ;;\n" @@ -87,7 +89,9 @@ static void write_stub(void) " select_network) echo OK ;;\n" " disconnect) echo OK ;;\n" " save_config)\n" - " if [ -f \"$D/save_fail_once\" ]; then rm -f \"$D/save_fail_once\"; echo FAIL; else echo OK; fi\n" + " v=\"\"\n" + " if [ -f \"$D/save_fail_once\" ]; then IFS= read -r v < \"$D/save_fail_once\"; fi\n" + " if [ \"$v\" = \"1\" ]; then echo \"\" > \"$D/save_fail_once\"; echo FAIL; else echo OK; fi\n" " ;;\n" " set) echo OK ;;\n" " remove_network) echo OK ;;\n" @@ -101,7 +105,10 @@ static void write_stub(void) int main(void) { - snprintf(g_dir, sizeof g_dir, "/tmp/mnwifi.XXXXXX"); + /* 宿主临时目录:Termux/Android 上 /tmp 不可写 → 跟随 $TMPDIR */ + const char *tmp = getenv("TMPDIR"); + if (!tmp || !tmp[0]) tmp = "/tmp"; + snprintf(g_dir, sizeof g_dir, "%s/mnwifi.XXXXXX", tmp); if (!mkdtemp(g_dir)) { fprintf(stderr, "mkdtemp fail\n"); return 2; } write_stub(); setenv("MN_WIFI_DIR", g_dir, 1); @@ -110,7 +117,7 @@ int main(void) g_connect_wait_ms = 400; g_connecth_wait_ms = 400; { - char p[192]; + static char p[192]; snprintf(p, sizeof p, "%s/wpa_cli", g_dir); g_wpa_cli = p; } From 239cebae0139382b931b7f4ccd695802893c4e50 Mon Sep 17 00:00:00 2001 From: worker-m49 Date: Wed, 11 Nov 2026 19:15:00 +0800 Subject: [PATCH 2/2] =?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"