fix(bt): M49 r1 评审 — 写操作预算压回 1500ms 窗口内 + A2DP 总预算/TTL 缓存 + 测试可重复 + rfkill 顺序;rebase 到 main 保住 M43 time32

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 未动。
This commit is contained in:
worker-m49 committed 2026-11-11 19:15:00 +08:00
1 parent 370c290160
commit 239cebae01
4 files changed
+470 -114

No files matched your search

+221 -67
View File
@@ -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 <mac>` 的布尔字段真态(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 <MAC> <Name>"(去重)。
* 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) {