// ==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 = `
🔵📡 蓝牙工具 + 无线中继
蓝牙未连接 中继WS 断开
① 蓝牙操作
已发现服务 / 特征值
(未连接)
服务 UUID 特征值 UUID
写入数据 (十六进制,如 01 0a ff)
② 无线中继
中继服务端 WebSocket 地址
⚙ 设置
设备名称过滤(空=任意) 接受任意设备 可选服务(逗号分隔 UUID 或别名) 客户端 ID
日志
`; document.body.appendChild(p); document.getElementById('br-min').onclick = () => { const b = document.getElementById('br-body'); b.style.display = b.style.display === 'none' ? 'block' : 'none'; }; makeDraggable(p, document.getElementById('br-head')); // 蓝牙区 document.getElementById('br-scan').onclick = startScan; document.getElementById('br-disco').onclick = () => { if (btDevice) { btDevice.disconnect(); setBTStatus(false); } }; document.getElementById('br-read').onclick = async () => { if (!btDevice) return log('warn', '请先扫描并连接设备'); const { s, c } = getUuids(); if (!s || !c) return log('warn', '请填写服务与特征值 UUID'); try { await btDevice.read(s, c); } catch (e) { log('error', e.message); } }; document.getElementById('br-sub').onclick = async () => { if (!btDevice) return log('warn', '请先扫描并连接设备'); const { s, c } = getUuids(); if (!s || !c) return log('warn', '请填写服务与特征值 UUID'); try { await btDevice.subscribe(s, c, () => {}); } catch (e) { log('error', e.message); } }; document.getElementById('br-write').onclick = () => onWrite(true); document.getElementById('br-write-nr').onclick = () => onWrite(false); // 中继区 document.getElementById('br-wsconn').onclick = () => { config.wsUrl = document.getElementById('br-ws-url').value.trim(); saveConfig(); connectWS(); }; document.getElementById('br-auto-relay').onchange = (e) => { config.autoRelay = e.target.checked; saveConfig(); }; document.getElementById('br-notify-all').onchange = (e) => { config.notifyAll = e.target.checked; saveConfig(); }; // 设置区 document.getElementById('cfg-save').onclick = () => { config.deviceName = document.getElementById('cfg-name').value.trim(); config.acceptAllDevices = document.getElementById('cfg-accept').value === '1'; config.optionalServices = document.getElementById('cfg-svc').value.split(',').map(s => s.trim()).filter(Boolean); config.clientId = document.getElementById('cfg-cid').value.trim() || config.clientId; saveConfig(); log('info', '设置已保存'); }; if (!navigator.bluetooth) { document.getElementById('br-scan').disabled = true; log('error', '当前浏览器不支持 Web Bluetooth'); } } function getUuids() { return { s: document.getElementById('br-svc-uuid').value.trim(), c: document.getElementById('br-char-uuid').value.trim() }; } async function onWrite(response) { if (!btDevice) return log('warn', '请先扫描并连接设备'); const { s, c } = getUuids(); const hex = document.getElementById('br-write-hex').value.trim(); if (!s || !c || !hex) return log('warn', '请填写 UUID 与写入数据'); try { await btDevice.write(s, c, hexToBuf(hex), response); } catch (e) { log('error', e.message); } } function renderServices() { const box = document.getElementById('br-svc'); if (!btDevice || !btDevice.services.length) { box.textContent = '(无服务)'; return; } box.innerHTML = btDevice.services.map(s => `
${s.uuid}` + s.characteristics.map(c => `
└ ${c.uuid} ` + `[${['read', 'write', 'notify', 'indicate', 'writeWithoutResponse'].filter(p => c.props[p]).join('/')}]
`).join('') + `
` ).join(''); } function makeDraggable(panel, handle) { let sx, sy, ox, oy, drag = false; handle.addEventListener('mousedown', e => { drag = true; sx = e.clientX; sy = e.clientY; const r = panel.getBoundingClientRect(); ox = r.left; oy = r.top; panel.style.left = ox + 'px'; panel.style.top = oy + 'px'; panel.style.right = 'auto'; }); window.addEventListener('mousemove', e => { if (!drag) return; panel.style.left = (ox + e.clientX - sx) + 'px'; panel.style.top = (oy + e.clientY - sy) + 'px'; }); window.addEventListener('mouseup', () => drag = false); } /* ========================================================= * 7. 启动 * =======================================================*/ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { buildUI(); connectWS(); }); } else { buildUI(); connectWS(); } // 暴露给控制台 / 其它脚本 window.BLETool = { config, getWS: () => ws, getDevice: () => btDevice, startScan, connectWS }; log('info', '蓝牙工具 + 无线中继 已加载。先「连接服务端」,再「扫描并连接」即可开始。'); })();