@esptool/mac 0.0.0-stage → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/bin/cli.js +158 -0
- package/package.json +37 -4
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# esptool.mac(@esptool/mac)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MacBook 用户专用的 ESP32 主机侧工具集,发布为 `@esptool/mac`。当前提供基于 `@stoprocent/noble` 的 BLE 扫描(经本机蓝牙适配器发现周边 BLE 广播设备)。
|
|
4
|
+
|
|
5
|
+
## 使用(消费约定与 @esptool/cli 相同)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx -y @esptool/mac scan # 扫描 10s(⏳ 待发布,发布前用下面的本地方式)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
本地开发:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun install
|
|
15
|
+
bun bin/cli.js scan -t 8 # 指定时长;-t 0 持续扫描直到 Ctrl+C
|
|
16
|
+
bun bin/cli.js scan -n S3 # 按设备名过滤
|
|
17
|
+
bun bin/cli.js scan -u ffff # 按广播 service UUID 过滤(BluFi 设备指纹)
|
|
18
|
+
bun bin/cli.js scan -o # 只显示本仓库设备(规则见 OUR_SIGNATURES)
|
|
19
|
+
bun bin/cli.js scan -j # JSON 输出(不含表头与实时行,可直接管道给 jq/node)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 说明
|
|
23
|
+
|
|
24
|
+
- 运行时兼容 node 与 bun:bin 入口 `#!/usr/bin/env node`(npx 场景),本地也可 `bun bin/cli.js` 直跑。
|
|
25
|
+
- macOS 下设备 id 是 CoreBluetooth 生成的随机 UUID,非真实 MAC。
|
|
26
|
+
- 依赖安装时 bun 会阻止 `bluetooth-hci-socket` 的 postinstall(Linux 原生模块),macOS 无需信任,不影响使用。
|
|
27
|
+
- 发布:`bun run release`(npm version patch + bun publish)。
|
|
28
|
+
|
|
29
|
+
## 文档
|
|
30
|
+
|
|
31
|
+
- [BLE 扫描 PoC 工作报告](docs/2026-10-25-scan-poc-report.md)
|
|
32
|
+
- [BLE 广播标识统一规范提案](docs/2026-10-25-ble-advertising-spec-proposal.md)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import noble from '@stoprocent/noble'
|
|
3
|
+
|
|
4
|
+
const HELP = `
|
|
5
|
+
@esptool/mac — MacBook 专用主机工具(BLE 扫描等)
|
|
6
|
+
|
|
7
|
+
用法: npx @esptool/mac scan [选项]
|
|
8
|
+
或: bun bin/cli.js scan [选项]
|
|
9
|
+
|
|
10
|
+
选项:
|
|
11
|
+
-t, --timeout <秒> 扫描时长 (默认 10, 0 = 持续扫描直到 Ctrl+C)
|
|
12
|
+
-n, --name <关键字> 按设备名过滤 (大小写不敏感)
|
|
13
|
+
-u, --service <uuid> 按广播 service UUID 过滤 (如 ffff, 忽略大小写与连字符)
|
|
14
|
+
-o, --our 只显示本仓库设备 (BluFi ffff 服务, 或 KDB- 名字前缀)
|
|
15
|
+
-d, --duplicates 响应重复广播 (实时刷新 RSSI)
|
|
16
|
+
-j, --json 以 JSON 输出最终结果
|
|
17
|
+
-h, --help 显示帮助
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
const normalizeUuid = (uuid) => String(uuid).replace(/-/g, '').toLowerCase()
|
|
21
|
+
|
|
22
|
+
// 自家设备识别:命中任一签名即算。多条并存是为了覆盖规范落地前后各种形态,
|
|
23
|
+
// 随命名规范推进逐步收紧(见 docs/2026-10-25-ble-advertising-spec-proposal.md §2/§3)。
|
|
24
|
+
const OUR_SIGNATURES = [
|
|
25
|
+
{ service: 'ffff' }, // BluFi 配网固件
|
|
26
|
+
{ namePrefix: 'kdb-' }, // 完整格式名 KDB-<产品线>-<型号>-<标识>
|
|
27
|
+
{ namePattern: /^[a-z]{2}-[0-9a-f]{2}$/ }, // 短码 <产品线2>-<MAC末1字节hex2>,如 S3-1F
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const isOurs = (name, services) => {
|
|
31
|
+
const lowerName = name.toLowerCase()
|
|
32
|
+
return OUR_SIGNATURES.some(
|
|
33
|
+
(sig) =>
|
|
34
|
+
(sig.service && services.some((s) => s === sig.service || s.endsWith(sig.service))) ||
|
|
35
|
+
(sig.namePrefix && lowerName.startsWith(sig.namePrefix)) ||
|
|
36
|
+
(sig.namePattern && sig.namePattern.test(lowerName))
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parseArgs = (argv) => {
|
|
41
|
+
const opts = { timeout: 10, name: '', service: '', our: false, duplicates: false, json: false }
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i]
|
|
44
|
+
if (a === '-t' || a === '--timeout') opts.timeout = Number(argv[++i])
|
|
45
|
+
else if (a === '-n' || a === '--name') opts.name = argv[++i]
|
|
46
|
+
else if (a === '-u' || a === '--service') opts.service = normalizeUuid(argv[++i])
|
|
47
|
+
else if (a === '-o' || a === '--our') opts.our = true
|
|
48
|
+
else if (a === '-d' || a === '--duplicates') opts.duplicates = true
|
|
49
|
+
else if (a === '-j' || a === '--json') opts.json = true
|
|
50
|
+
else if (a === '-h' || a === '--help') {
|
|
51
|
+
console.log(HELP)
|
|
52
|
+
process.exit(0)
|
|
53
|
+
} else {
|
|
54
|
+
console.error(`未知参数: ${a}`)
|
|
55
|
+
console.log(HELP)
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return opts
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const toHex = (buf) => (Buffer.isBuffer(buf) ? buf.toString('hex') : '')
|
|
63
|
+
|
|
64
|
+
const toRecords = (devices) =>
|
|
65
|
+
[...devices.values()].sort((x, y) => y.rssi - x.rssi)
|
|
66
|
+
|
|
67
|
+
const printTable = (records, elapsed) => {
|
|
68
|
+
console.log(`\n共发现 ${records.length} 台设备 (耗时 ${elapsed}s),按信号强度排序:\n`)
|
|
69
|
+
const pad = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n))
|
|
70
|
+
console.log(` ${'RSSI'.padStart(8)} ${pad('UUID / ADDRESS', 38)}${pad('NAME', 24)}${pad('SERVICES', 10)}MFG DATA`)
|
|
71
|
+
for (const d of records) {
|
|
72
|
+
const services = d.services.join(',')
|
|
73
|
+
console.log(
|
|
74
|
+
` ${`${d.rssi} dBm`.padStart(8)} ${pad(d.id, 38)}${pad(d.name || '(未命名)', 24)}${pad(services, 10)}${d.mfg}`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const scan = async (opts) => {
|
|
80
|
+
const devices = new Map()
|
|
81
|
+
const startedAt = Date.now()
|
|
82
|
+
|
|
83
|
+
noble.on('discover', (p) => {
|
|
84
|
+
const name = p.advertisement?.localName || ''
|
|
85
|
+
if (opts.name && !name.toLowerCase().includes(opts.name.toLowerCase())) return
|
|
86
|
+
|
|
87
|
+
const services = (p.advertisement?.serviceUuids ?? []).map(normalizeUuid)
|
|
88
|
+
if (opts.service && !services.some((s) => s === opts.service || s.endsWith(opts.service))) return
|
|
89
|
+
if (opts.our && !isOurs(name, services)) return
|
|
90
|
+
|
|
91
|
+
const prev = devices.get(p.id)
|
|
92
|
+
if (prev) {
|
|
93
|
+
prev.rssi = p.rssi
|
|
94
|
+
prev.count += 1
|
|
95
|
+
if (!prev.name && name) prev.name = name
|
|
96
|
+
if (!prev.services.length && services.length) prev.services = services
|
|
97
|
+
if (!opts.duplicates) return
|
|
98
|
+
} else {
|
|
99
|
+
devices.set(p.id, {
|
|
100
|
+
id: p.id,
|
|
101
|
+
name,
|
|
102
|
+
rssi: p.rssi,
|
|
103
|
+
connectable: p.connectable ?? null,
|
|
104
|
+
mfg: toHex(p.advertisement?.manufacturerData),
|
|
105
|
+
services,
|
|
106
|
+
firstSeen: Date.now() - startedAt,
|
|
107
|
+
count: 1,
|
|
108
|
+
})
|
|
109
|
+
if (!opts.json) {
|
|
110
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
|
|
111
|
+
console.log(` [${elapsed.padStart(5)}s] ${String(p.rssi).padStart(4)} dBm ${p.id} ${name || '(未命名)'}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
const onState = (state) => (state === 'poweredOn' ? resolve() : undefined)
|
|
118
|
+
noble.on('stateChange', onState)
|
|
119
|
+
noble.on('warning', (w) => console.error(`[noble] ${w}`))
|
|
120
|
+
if (noble.state === 'poweredOn') resolve()
|
|
121
|
+
setTimeout(() => reject(new Error(`蓝牙适配器未就绪 (state: ${noble.state})`)), 5000)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (!opts.json) {
|
|
125
|
+
console.log(`开始扫描 (${opts.timeout === 0 ? '持续模式, Ctrl+C 结束' : `${opts.timeout}s`})…\n`)
|
|
126
|
+
}
|
|
127
|
+
await noble.startScanningAsync([], opts.duplicates)
|
|
128
|
+
|
|
129
|
+
const finish = async () => {
|
|
130
|
+
noble.removeAllListeners('discover')
|
|
131
|
+
await noble.stopScanningAsync().catch(() => {})
|
|
132
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
|
|
133
|
+
const records = toRecords(devices)
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
console.log(JSON.stringify({ elapsed: Number(elapsed), count: records.length, devices: records }, null, 2))
|
|
136
|
+
} else {
|
|
137
|
+
printTable(records, elapsed)
|
|
138
|
+
}
|
|
139
|
+
process.exit(0)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
process.on('SIGINT', finish)
|
|
143
|
+
if (opts.timeout > 0) setTimeout(finish, opts.timeout * 1000)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const main = async () => {
|
|
147
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
148
|
+
if (cmd !== 'scan') {
|
|
149
|
+
console.log(HELP)
|
|
150
|
+
process.exit(cmd ? 1 : 0)
|
|
151
|
+
}
|
|
152
|
+
await scan(parseArgs(rest))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main().catch((err) => {
|
|
156
|
+
console.error(`错误: ${err.message}`)
|
|
157
|
+
process.exit(1)
|
|
158
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esptool/mac",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "MacBook host-side tools for ESP32 workflow - BLE scanner and more",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mac": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"scan": "bun bin/cli.js scan",
|
|
15
|
+
"test": "node bin/cli.js scan -t 3",
|
|
16
|
+
"release": "npm version patch && npm publish --access public"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@stoprocent/noble": "^2.3.2"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"esp32",
|
|
29
|
+
"bluetooth",
|
|
30
|
+
"ble",
|
|
31
|
+
"scanner",
|
|
32
|
+
"macos",
|
|
33
|
+
"macbook",
|
|
34
|
+
"blufi",
|
|
35
|
+
"cli"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"author": "Benz <ben.zeng@keepdb.com>"
|
|
39
|
+
}
|