// ==UserScript== // @name 蓝牙工具 + 无线中继 (合并版) // @namespace https://github.com/workbuddy/ble-relay-userjs // @version 2.0.0 // @description 蓝牙设备扫描/连接、Web Bluetooth API 封装、特征值读写订阅,以及通过 WebSocket 把蓝牙数据无线转发到远端并接收远端指令反向控制。单文件合并版(含配置面板,可连接任意 WebSocket 中继服务端)。依赖浏览器支持 Web Bluetooth(Chrome/Edge 且 https 页面)。 // @match *://*/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; /* ========================================================= * 1. 配置(持久化到 localStorage) * =======================================================*/ const STORAGE_KEY = 'ble_tool_relay_config_v1'; const DEFAULT_CONFIG = { // 蓝牙 deviceName: '', acceptAllDevices: true, optionalServices: ['battery_service', 'device_information'], notifyAll: false, // 中继 wsUrl: 'ws://127.0.0.1:8080', clientId: 'browser-' + Math.random().toString(36).slice(2, 8), autoRelay: true, // 通用 maxLogLines: 400, panelPos: 'right' }; const config = (() => { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? Object.assign({}, DEFAULT_CONFIG, JSON.parse(raw)) : Object.assign({}, DEFAULT_CONFIG); } catch (e) { return Object.assign({}, DEFAULT_CONFIG); } })(); function saveConfig() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); } catch (e) {} } /* ========================================================= * 2. 工具 & 日志 * =======================================================*/ const logLines = []; function log(level, msg) { const t = new Date().toLocaleTimeString(); const line = `[${t}] ${level.toUpperCase()} ${msg}`; logLines.push(line); while (logLines.length > config.maxLogLines) logLines.shift(); const box = document.getElementById('br-log'); if (box) { box.textContent = logLines.join('\n'); box.scrollTop = box.scrollHeight; } } const bufToHex = (buf) => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(' '); const hexToBuf = (hex) => { const bytes = hex.trim().split(/\s+/).map(h => parseInt(h, 16)); if (bytes.some(isNaN)) throw new Error('十六进制格式错误'); return new Uint8Array(bytes).buffer; }; const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); /* ========================================================= * 3. Web Bluetooth 封装(共享) * =======================================================*/ class BLEDevice { constructor(device) { this.device = device; this.server = null; this.services = []; } async connect() { if (!this.server) this.server = await this.device.gatt.connect(); const svcs = await this.server.getPrimaryServices(); this.services = []; for (const s of svcs) { const chars = await s.getCharacteristics(); this.services.push({ uuid: s.uuid, characteristics: chars.map(c => ({ uuid: c.uuid, props: c.properties })) }); } this.device.addEventListener('gattserverdisconnected', () => { log('warn', '设备断开'); setBTStatus(false); }); return this; } async getChar(s, c) { return (await this.server.getPrimaryService(s)).getCharacteristic(c); } async read(s, c) { const v = await (await this.getChar(s, c)).readValue(); log('info', `读取 ${c}: ${bufToHex(v.buffer)}`); return v.buffer; } async write(s, c, data, response = true) { const ch = await this.getChar(s, c); if (ch.writeValueWithResponse) response ? await ch.writeValueWithResponse(data) : await ch.writeValueWithoutResponse(data); else await ch.writeValue(data); log('info', `写入 ${c}: ${bufToHex(data)}`); } async subscribe(s, c, cb) { const ch = await this.getChar(s, c); if (!ch.properties.notify && !ch.properties.indicate) throw new Error(c + ' 不支持通知'); await ch.startNotifications(); ch.addEventListener('characteristicvaluechanged', e => cb(e.target.value.buffer, e.target.value)); log('info', `已订阅 ${c}`); } disconnect() { if (this.device.gatt.connected) this.device.gatt.disconnect(); } } // 共享设备实例:蓝牙工具区与中继区共用同一个连接 let btDevice = null; function setBTStatus(on, name) { const el = document.getElementById('br-bt'); if (!el) return; el.className = 'br-badge ' + (on ? 'br-ok' : 'br-off'); el.textContent = on ? ('已连接 ' + (name || '')) : '未连接'; } function setWsStatus(on) { const el = document.getElementById('br-ws'); if (!el) return; el.className = 'br-badge ' + (on ? 'br-ok' : 'br-off'); el.textContent = on ? 'WS 已连' : 'WS 断开'; } /* ========================================================= * 4. WebSocket 无线中继 * =======================================================*/ let ws = null, wsConnected = false; function wsSend(obj) { if (ws && wsConnected) ws.send(JSON.stringify(obj)); } function connectWS() { if (!('WebSocket' in window)) { log('error', '浏览器不支持 WebSocket'); return; } try { ws = new WebSocket(config.wsUrl); } catch (e) { log('error', 'WS 连接失败: ' + e.message); return; } ws.onopen = () => { wsConnected = true; setWsStatus(true); log('info', '中继服务端已连接: ' + config.wsUrl); wsSend({ type: 'hello', clientId: config.clientId, role: 'ble-relay' }); }; ws.onclose = () => { wsConnected = false; setWsStatus(false); log('warn', '中继服务端断开,3 秒后重连…'); setTimeout(connectWS, 3000); }; ws.onerror = () => log('error', 'WS 错误'); ws.onmessage = (ev) => handleRemoteCommand(ev.data); } // 远端下发的控制指令 async function handleRemoteCommand(raw) { let msg; try { msg = JSON.parse(raw); } catch { return; } if (!msg.type || msg.type === 'hello' || msg.type === 'welcome') return; if (!btDevice) return log('warn', '收到指令但蓝牙未连接: ' + msg.type); try { if (msg.type === 'scan') { await startScan(); } else if (msg.type === 'read') { const buf = await btDevice.read(msg.service, msg.characteristic); wsSend({ type: 'readResult', service: msg.service, characteristic: msg.characteristic, data: bufToB64(buf), hex: bufToHex(buf), echo: msg.echo }); log('info', `远端读取 ${msg.characteristic}: ${bufToHex(buf)}`); } else if (msg.type === 'write') { const data = hexToBuf(msg.value || ''); await btDevice.write(msg.service, msg.characteristic, data, msg.response !== false); wsSend({ type: 'writeResult', ok: true, characteristic: msg.characteristic, echo: msg.echo }); log('info', `远端写入 ${msg.characteristic}: ${bufToHex(data)}`); } else if (msg.type === 'subscribe') { await btDevice.subscribe(msg.service, msg.characteristic, (buf) => { wsSend({ type: 'notify', service: msg.service, characteristic: msg.characteristic, data: bufToB64(buf), hex: bufToHex(buf) }); }); wsSend({ type: 'subscribeResult', ok: true, characteristic: msg.characteristic }); log('info', `远端订阅 ${msg.characteristic}`); } } catch (e) { wsSend({ type: 'error', message: e.message, echo: msg.echo }); log('error', '执行远端指令失败: ' + e.message); } } /* ========================================================= * 5. 扫描 / 连接 * =======================================================*/ async function startScan() { try { const opts = { optionalServices: config.optionalServices }; if (config.acceptAllDevices || !config.deviceName) opts.acceptAllDevices = true; else opts.filters = [{ name: config.deviceName }]; log('info', '唤起设备选择器…'); const device = await navigator.bluetooth.requestDevice(opts); btDevice = new BLEDevice(device); await btDevice.connect(); setBTStatus(true, device.name || device.id); renderServices(); log('info', '已连接 ' + (device.name || device.id)); if (config.notifyAll) await subscribeAllLocal(); if (config.autoRelay) await autoRelayAll(); wsSend({ type: 'deviceConnected', name: device.name || device.id, clientId: config.clientId }); } catch (e) { log('error', '扫描/连接失败: ' + e.message); } } // 本地订阅(仅日志查看) async function subscribeAllLocal() { for (const s of btDevice.services) for (const ch of s.characteristics) { if (ch.props.notify || ch.props.indicate) { try { await btDevice.subscribe(s.uuid, ch.uuid, (buf) => {}); } catch (e) { log('warn', '订阅失败 ' + ch.uuid + ': ' + e.message); } } } } // 自动订阅并无线转发(核心中继逻辑) async function autoRelayAll() { for (const s of btDevice.services) for (const ch of s.characteristics) { if (ch.props.notify || ch.props.indicate) { try { await btDevice.subscribe(s.uuid, ch.uuid, (buf) => { wsSend({ type: 'notify', service: s.uuid, characteristic: ch.uuid, data: bufToB64(buf), hex: bufToHex(buf) }); }); log('info', '已订阅并转发: ' + ch.uuid); } catch (e) { log('warn', '订阅失败 ' + ch.uuid + ': ' + e.message); } } } } /* ========================================================= * 6. UI 面板 * =======================================================*/ const STYLE = ` #br-panel{position:fixed;top:60px;z-index:2147483647;width:340px;max-height:84vh;overflow:auto; background:#fff;border:1px solid #cbd5e1;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.18); font:13px/1.5 system-ui,'Microsoft YaHei',sans-serif;color:#0f172a;} #br-panel.left{left:12px;} #br-panel.right{right:12px;} #br-head{display:flex;justify-content:space-between;align-items:center;padding:8px 12px; background:linear-gradient(90deg,#2563eb,#0ea5e9);color:#fff;font-weight:600;border-radius:9px 9px 0 0;cursor:move;} #br-head button{background:transparent;border:0;color:#fff;font-size:15px;cursor:pointer;} #br-body{padding:10px 12px;} #br-panel textarea,#br-panel input,#br-panel select{width:100%;box-sizing:border-box;border:1px solid #cbd5e1; border-radius:6px;padding:5px 7px;font:12px/1.4 monospace;} #br-panel button.act{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:6px 9px;cursor:pointer;margin:3px 3px 3px 0;} #br-panel button.act.alt{background:#0ea5e9;} #br-panel button.act:disabled{background:#94a3b8;cursor:not-allowed;} #br-panel .row{margin:8px 0;} #br-panel .lbl{font-size:12px;color:#475569;margin-bottom:3px;display:block;} #br-panel details{border:1px solid #e2e8f0;border-radius:8px;padding:6px 8px;margin:8px 0;} #br-panel summary{cursor:pointer;color:#2563eb;font-weight:600;} #br-svc{font:11px/1.4 monospace;max-height:150px;overflow:auto;background:#f1f5f9;border-radius:6px;padding:6px;} #br-log{background:#0f172a;color:#e2e8f0;height:150px;overflow:auto;white-space:pre-wrap; border-radius:6px;padding:6px;font:11px/1.4 monospace;margin-top:6px;} .br-badge{display:inline-block;padding:1px 6px;border-radius:10px;font-size:11px;} .br-ok{background:#dcfce7;color:#166534;} .br-off{background:#fee2e2;color:#991b1b;} `; function buildUI() { const style = document.createElement('style'); style.textContent = STYLE; document.head.appendChild(style); const p = document.createElement('div'); p.id = 'br-panel'; p.className = config.panelPos === 'left' ? 'left' : 'right'; p.innerHTML = `