ble-toy-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ble-toy-mcpset vibration to level 5"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
把蓝牙玩具接入 AI —— 从协议逆向到 MCP 自主控制
面向 MuSe / LoveSpouse / Leten 这一类"广播型"蓝牙玩具(常见 App:暮瑟、LoveSpouse、Leten 等)。 目标:用一台 Windows 电脑,让 AI 智能体(如 RikkaHub)通过 MCP 自主控制玩具档位。全程不连接玩具、不需要越狱/root、不需要抓 HCI 蓝牙包。
⚠️ 前置声明:仅用于控制你自己拥有的设备、用于个人学习与互操作研究。该协议无任何鉴权,任何能发广播的设备都能控制范围内的玩具;请勿把控制端点暴露到公网(详见"安全"一节)。
0. 成果预览
你对 AI 说:"调到震动5档" / "来一下吮吸3秒"
→ AI 自己决定调用 MCP 工具 set_vibration(5)
→ 你电脑上的 MCP 服务收到
→ 用 Windows 蓝牙广播一段厂商数据
→ 玩具收到并锁存该档位flowchart LR
U["你对 AI 说:调到震动5档"] --> AI["AI 智能体 RikkaHub"]
AI -->|"MCP 调用 set_vibration"| S["电脑 MCP 服务<br/>muse_server.py"]
S --> C["muse_control.py 查指令表"]
C -->|"winsdk BLE 广播"| T["玩具:锁存 5 档"]最终你得到:
muse_control.py—— 命令行/库,直接控制玩具muse_server.py—— MCP 服务,任何 MCP 客户端(RikkaHub/Claude/Cherry Studio…)都能接
Related MCP server: BLE MCP Server
1. 原理:它是"广播型",不是"连接型"
普通蓝牙设备(耳机、手环)要先配对、建连接、走 GATT 读写。 这类玩具不一样:它是个"只听不连"的被动接收器。 它不停扫描周围的 BLE 广播包,一旦发现某个广播的"厂商数据(manufacturer data)"符合它自己的签名,就照着执行。
类比:像电视遥控器对空气发红外——发射端只管广播,接收端自己接,双方不建立连接。
一条广播包(11 字节厂商数据)长这样:
company_id = 0xFF
000000 6DB643xxxxxx427C XX YY ZZ
└填充┘ └──── prefix(8B) ──┘ └指令3B┘
随玩具"地址"白化得到 档位+校验(CRC)prefix 是每只玩具的身份签名(由玩具的逻辑地址白化推导)。别的玩具签名不同,所以你的广播只有你这只会响应。
后 3 字节编码"哪个马达 + 第几档 + 校验",每只玩具也不同(含 CRC)。
这也解释了:为什么不用配对、不用玩具 MAC、不用 GATT UUID、也不用抓 HCI 包——因为根本不建立连接。
2. 前置准备
需要 | 说明 |
一台 Windows 10/11 电脑 | 必须,广播要靠它的蓝牙 LE 天线(WinRT API) |
玩具 + 官方 App | 用来抓你这只玩具的真实指令 |
安卓手机 + USB 调试 | 用 |
Python 3.10~3.12 | 装 |
装环境(推荐 uv,避免污染系统 Python):
uv venv --python 3.12
uv pip install winsdk3. 关键一步:拿到"你这只玩具的指令表"
flowchart TD
A["手机连玩具,官方App按各档位"] --> B["电脑 adb logcat 抓日志"]
B --> C["搜 startAdvertising 提取广播字节"]
C --> D["拆分:前8字节=prefix,后3字节=指令"]
D --> E["填进 muse_control.py 的 MODE1/MODE2"]
E --> F["winsdk 广播验证,玩具动=成功"]所有公开实现都只硬编码了一只演示玩具。你必须拿到自己这只的 prefix + 指令。三条路,优先第 ①:
💡 不需要任何账号 / 登录 / UserID 信息。 指令字节是跟着玩具硬件走的,不是跟账号走的——你只是在读自己手机的日志、抓自己玩具的字节。全程零账号、零服务器会话。(只有下面进阶备选 ③ 才会用到登录会话,绝大多数人用不到。)
① adb logcat 直接抓(最省事,推荐)
很多这类 App 会把要广播的字节原样打进日志。做法:
# 电脑装好 platform-tools(adb),手机开 USB 调试并授权
adb logcat -c # 清空日志
adb logcat -v time > log.txt # 开始录制(另开一个窗口)然后在手机 App 里连上玩具,把每个模式/每个档位从低到高按一遍(每档停 2~3 秒)。
按完在 log.txt 里搜广播特征。以暮瑟为例,日志里直接有:
I/联网 蓝牙指令:31 : 6d b6 43 ce 97 fe 42 7c d4 1f 5d
W/tag startAdvertising: 6db643ce97fe427cd41f5d搜索关键词建议:
startAdvertising、advertis、manufacturer、6db643(prefix 特征)、蓝牙指令、ble、cmd。 每条 11 字节里,前 8 字节是 prefix(全档相同),后 3 字节随档位变——把它们按"模式+档位"整理成表即可。
② 公开指令表(碰运气)
GitHub arz321/MuSe-Protocol 里的 MuSe_bleCommand.txt 收录了 6 只玩具的完整 256 指令表。若你的 prefix 恰好和其中一只一致,直接抄表即可。
③ 服务器 / 逆向 APK(进阶)
该家族后端多为
*.zlmicro.com,App 会按玩具 barcode 从getproductdetail拉表(需抓包 + 会话)。或用 jadx 反编译 APK,找
BleIpaoUtils里"地址→prefix+CRC"的白化算法,给任意地址现算。
4. 用 Python 广播控制(muse_control.py)
把你抓到的指令填进 MODE1/MODE2(下标 0=停,1~9=强度)。下面是暮瑟实测可用的表(你的可能不同,以自己抓到的为准):
#!/usr/bin/env python3
"""MuSe/LoveSpouse 广播型玩具控制器 —— Windows 原生(winsdk)。"""
from __future__ import annotations
import sys, time
try:
sys.stdout.reconfigure(encoding="utf-8") # 防 GBK 控制台中文崩溃
except Exception:
pass
import winsdk.windows.devices.bluetooth.advertisement as _adv
import winsdk.windows.storage.streams as _streams
from winsdk.windows.devices.bluetooth.advertisement import (
BluetoothLEAdvertisementPublisherStatus as _PubStatus,
)
PREFIX_HEX = "0000006db643ce97fe427c" # 000000 + 你玩具的 prefix(8字节)
COMPANY_ID = 0xFF # 若不响应,试 0xFFF0(见"坑")
# 下标 0=停,1~9=强度。换成你自己抓到的字节:
MODE1 = ["d5964c","d41f5d","d7846f","d60d7e","d1b20a",
"d03b1b","d3a029","d22938","dddec0","dc57d1"] # 震动
MODE2 = ["a5113f","a4982e","a7031c","a68a0d","a13579",
"a0bc68","a3275a","a2ae4b","ad59b3","acd0a2"] # 吮吸/伸缩
class MuseController:
def __init__(self, prefix_hex=PREFIX_HEX, company_id=COMPANY_ID):
self._prefix = prefix_hex.replace(" ", "").lower()
self._company_id = company_id
self.level1 = 0; self.level2 = 0
def mode1(self, level, duration=0.6):
lv = max(0, min(9, int(level))); self._send(MODE1[lv], duration); self.level1 = lv
def mode2(self, level, duration=0.6):
lv = max(0, min(9, int(level))); self._send(MODE2[lv], duration); self.level2 = lv
def stop(self, duration=0.6):
self._send(MODE1[0], duration); self._send(MODE2[0], duration)
self.level1 = 0; self.level2 = 0
def _send(self, cmd_hex, duration):
payload = bytearray.fromhex(self._prefix + cmd_hex)
pub = _adv.BluetoothLEAdvertisementPublisher()
md = _adv.BluetoothLEManufacturerData(); md.company_id = self._company_id
w = _streams.DataWriter(); w.write_bytes(payload); md.data = w.detach_buffer()
pub.advertisement.manufacturer_data.append(md)
pub.start()
while pub.status != _PubStatus.STARTED: time.sleep(0.01)
time.sleep(max(0.05, duration)); pub.stop()
if __name__ == "__main__":
c = MuseController()
a = sys.argv[1:]
if not a: print("用法: m1|m2 <0-9> | stop"); raise SystemExit
if a[0]=="m1": c.mode1(int(a[1]) if len(a)>1 else 1)
elif a[0]=="m2": c.mode2(int(a[1]) if len(a)>1 else 1)
elif a[0]=="stop": c.stop()测试(玩具开机、关掉 App、放电脑旁几米内):
.venv/Scripts/python muse_control.py m1 3 # 震动3档,会锁存
.venv/Scripts/python muse_control.py stop玩具动了 = 成功。它会锁存在该档位,直到你发下一条,所以发一小下就够。
5. 包成 MCP 服务(muse_server.py)
让 AI 能调用。用官方 MCP SDK 的 FastMCP,走 Streamable HTTP:
uv pip install mcp uvicorn#!/usr/bin/env python3
import asyncio, sys
from concurrent.futures import ThreadPoolExecutor
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
from mcp.server.fastmcp import FastMCP
from muse_control import MuseController
HOST, PORT = "0.0.0.0", 8765
_ctl = MuseController()
_ex = ThreadPoolExecutor(max_workers=1) # 所有广播在同一线程串行,COM状态稳定
mcp = FastMCP("muse-toy", host=HOST, port=PORT)
async def _run(fn, *args):
await asyncio.get_running_loop().run_in_executor(_ex, fn, *args)
@mcp.tool()
async def set_vibration(level: int) -> str:
"""设置震动强度并保持。level 0~9:0=关闭,9=最强。"""
lv = max(0, min(9, int(level))); await _run(_ctl.mode1, lv)
return f"震动 -> {lv} 档"
@mcp.tool()
async def set_suction(level: int) -> str:
"""设置吮吸/伸缩强度并保持。level 0~9。"""
lv = max(0, min(9, int(level))); await _run(_ctl.mode2, lv)
return f"吮吸/伸缩 -> {lv} 档"
@mcp.tool()
async def pulse(target: str, level: int, seconds: float = 3.0) -> str:
"""定时刺激后自动停。target=vibration|suction, level 1~9, seconds<=30。"""
lv = max(0, min(9, int(level))); secs = max(0.2, min(30.0, float(seconds)))
setter = _ctl.mode1 if target.lower().startswith(("vib","震")) else _ctl.mode2
await _run(setter, lv); await asyncio.sleep(secs); await _run(setter, 0)
return f"{target} {lv}档 持续{secs:g}s 已停"
@mcp.tool()
async def stop() -> str:
"""停止所有马达。"""
await _run(_ctl.stop); return "已全部停止"
@mcp.tool()
async def get_status() -> str:
"""查询当前档位。"""
return f"震动={_ctl.level1}, 吮吸/伸缩={_ctl.level2}"
if __name__ == "__main__":
mcp.run(transport="streamable-http")启动:
.venv/Scripts/python muse_server.py
# 端点: http://<本机IP>:8765/mcp6. 接入 RikkaHub(或任意 MCP 客户端)
flowchart LR
P["手机 RikkaHub(同一WiFi)"] -->|"http 局域网"| PC["电脑:MCP服务 + 蓝牙"]
PC -.->|"BLE 广播"| Toy["玩具"]
P2["手机(外网/4G)"] -.->|"可选:走 FRP"| VPS["VPS(仅隧道转发)"]
VPS -.-> PCRikkaHub → 设置 → MCP → 「+」→ 选 Streamable HTTP:
名称:
玩具URL:
http://<电脑局域网IP>:8765/mcp(手机与电脑同一 WiFi)
保存后状态变 Connected,会同步出 5 个工具。再 长按助手 → 编辑 → 启用该 MCP。之后对 AI 说"调到震动5档",它就会自己调 set_vibration(5)。
换成 Claude Desktop / Cherry Studio 等也一样,填这个 URL 即可。
7. 让它常驻(开机自启)
BLE 广播必须在登录后的桌面会话里跑(装成系统服务/Docker 拿不到蓝牙)。最简单:
建一个自重启批处理
run_server.bat:@echo off cd /d <项目目录> :loop .venv\Scripts\python.exe muse_server.py timeout /t 3 >nul goto loop建
launch_hidden.vbs(隐藏窗口启动):CreateObject("WScript.Shell").Run "<项目目录>\run_server.bat", 0, False把
launch_hidden.vbs放进启动文件夹(Win+R→shell:startup)→ 登录即自动运行。
局域网 IP 会变的话:在路由器里给这台电脑做 DHCP 地址保留(绑 MAC → 固定 IP),URL 就永久不变。
8. 常见坑
玩具不响应:①玩具没开机/太远;②官方 App 还连着在抢广播,先关 App;③把
COMPANY_ID从0xFF换成0xFFF0(不同实现的厂商 ID 框架不同,ESP32 版用后者)。控制台中文报错
UnicodeEncodeError:脚本已加sys.stdout.reconfigure(encoding="utf-8");或运行前set PYTHONUTF8=1。python/pip指向诡异路径:Windows 商店占位符劫持,用独立 venv 的全路径.venv\Scripts\python.exe即可绕开。手机连不上 MCP:先确认手机没开着废弃的 WiFi 代理;用 IP 而非
xxx.local(很多手机/路由不解析 mDNS);确认防火墙放行端口:netsh advfirewall firewall add rule name=muse-mcp dir=in action=allow protocol=TCP localport=8765
9. 安全
该协议无鉴权,广播范围内谁都能控。只在家里局域网用,别用 FRP/端口转发暴露到公网。
若确需外网:用 VPS + FRP 做隧道(VPS 不发蓝牙,只转发),并给 MCP 加一个 token(校验
Authorizationheader,RikkaHub 支持自定义 header)。
附录 A:进阶方向 —— 逆向 APK 求"通用算法"(⚠️ 方向指引,未亲测)
主路径 ①(logcat)已经够用,除非:App 不打印字节(logcat 抓不到),或你想不抓包、按地址直接算出任意玩具的指令。 下面只是方向,作者没亲自做通,不保证顺利。懒得折腾就忽略本附录。
为什么值得:目前所有公开项目都只硬编码了一只玩具;把"地址 → prefix + CRC"的通用算法逆出来,是全网独一份。
大致思路:
拿 APK:
adb shell pm path <包名>找到 base.apk,adb pull下来。反编译:jadx-gui 打开(APK 大就给足内存);或 apktool 出 smali。
定位关键类:R8/ProGuard 重度混淆,类名方法名会变成
a/b/c,别指望按名字搜。改用固定字符串反查:prefix 里恒定的6d b6 43/42 7c、日志 tag(如蓝牙指令/startAdvertising)、ManufacturerData、BluetoothLeAdvertiser等,顺着交叉引用找到组包逻辑(即之前提到的BleIpaoUtils/doubleCmdMap这类)。要逆的两段:
玩具地址 → prefix 中间 3 字节(白化)
(地址 + 指令码) → 后 2 字节(疑似 CRC-16)
第 1 字节(通道+档位)是固定表,好认;难在白化和 CRC 的多项式/初值。
验证:用 Python 重写算法,喂已知的一只玩具地址,看能否算出你实抓到的字节——对得上就成了。
坑 / 提示:
关键算法可能在 native
.so里,那得上 Ghidra/IDA,更硬核。"白化"很可能是 BLE 的 PN9 数据白化 + 某种 CRC-16,可往这个方向猜。
再次强调:未亲测,是给想深挖的人指路,不是保证能照抄成功的步骤。
10. 致谢 / 参考
本教程站在这些开源逆向工作的肩膀上:
arz321/MuSe-Protocol—— MuSe 指令表 + 玩具库RevenantFreddy/pylovespouse—— winsdk 广播的 Python 实现HackShiitake/LoveSpouse-Vibration-Controller—— Windows 控制器IngeniousKink/LVS-Gateway—— ESP32 网关(指令语义注释)51enuxu/sosexy-ble-control—— 同类 12 字节协议参考
方法通用,数据自备。控制你自己的设备,玩得开心也注意安全。
This server cannot be installed
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Sfrl79/ble-toy-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server