@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.
Files changed (3) hide show
  1. package/README.md +31 -2
  2. package/bin/cli.js +158 -0
  3. package/package.json +37 -4
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # esptool.mac(@esptool/mac)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }