commit 4065b9885139d1a4e618888f6f1557fe1a3c07b4 Author: xiaoli-cli bot Date: Thu Apr 30 02:27:19 2026 +0800 feat: 初始化 xiaoli-cli 插件仓库 - 添加 DG-Lab 郊狼 WebSocket 控制插件 (dglab_ws.py) - 基于 DG-LAB-OPENSOURCE 官方 Socket V2 协议 - 支持连接、配对、强度控制、波形发送 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d497e87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +*.egg-info/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8aaff51 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# xiaoli-cli-plugins + +小狸 CLI 官方插件仓库,存放社区贡献的扩展插件。 + +## 插件列表 + +| 插件 | 说明 | +|------|------| +| [dglab_ws](./dglab_ws.py) | DG-Lab 郊狼 3.0 WebSocket 控制插件 | + +## 安装方式 + +将插件 `.py` 文件复制到 xiaoli-cli 项目的 `plugins/` 目录下,然后在 `plugins_config.py` 中启用: + +```python +ENABLE_DGLAB_WS = True # 启用 DG-Lab 插件 +``` + +## 插件开发 + +每个插件是一个独立的 `.py` 文件,需包含 `Liugin` 类(兼容 `Plugin` 类名): + +```python +class Liugin: + def get_tool_info(self): + return { + "name": "插件名", + "description": "插件描述", + "keywords": ["关键词1", "关键词2"], + "usage": "使用说明" + } + + def handle(self, args): + return "处理结果" + + # 可选:注册CLI命令 + def set_cli(self, cli): + self.cli = cli + + # 可选:MCP支持 + def get_mcp_definition(self): + return {...} + + def convert_mcp_args(self, arguments): + return "转换后的参数" +``` + +## License + +MIT diff --git a/dglab_ws.py b/dglab_ws.py new file mode 100644 index 0000000..08ce3c5 --- /dev/null +++ b/dglab_ws.py @@ -0,0 +1,567 @@ +""" +DG-Lab Coyote WebSocket 控制插件 +基于 DG-LAB-OPENSOURCE 官方 Socket V2 协议 +支持郊狼 3.0 脉冲主机的 WebSocket 远程控制 + +参考文档: +- https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE +- Socket V2 协议: socket/v2/README.md +""" + +import json +import time +import threading +import uuid +import os + +try: + import websocket + HAS_WS = True +except ImportError: + HAS_WS = False + +try: + import qrcode + HAS_QRCODE = True +except ImportError: + HAS_QRCODE = False + + +class Liugin: + """DG-Lab 郊狼 WebSocket 控制插件""" + + def __init__(self): + self.ws = None + self.client_id = "" + self.target_id = "" + self.connected = False + self.paired = False + self.ws_url = "" + self.strength_a = 0 + self.strength_b = 0 + self.limit_a = 100 + self.limit_b = 100 + self._heartbeat_thread = None + self._running = False + + self.usage = """DG-Lab 郊狼控制插件 使用说明: + +/dglab connect - 连接到 WebSocket 服务器 +/dglab qr - 生成配对二维码 (需APP扫码) +/dglab status - 查看连接状态和通道强度 +/dglab strength <+|-|=|set> - 控制通道强度 + ch: a/b (通道A/B) + 操作: +增加, -减少, =设置为指定值 + 示例: /dglab strength a + 10 + /dglab strength b = 50 +/dglab pulse - 发送波形 + ch: a/b + freq: 频率 (10-240) + intensity: 强度 (0-100) + duration: 持续秒数 + 示例: /dglab pulse a 30 50 5 +/dglab wave - 发送原始波形HEX数据 + 示例: /dglab wave a 0A0A0A0A64646464 +/dglab clear - 清空通道波形队列 (a/b/both) +/dglab limit - 设置通道强度软上限 (0-200) +/dglab disconnect - 断开连接 +""" + + def set_cli(self, cli): + """注册 CLI 命令""" + self.cli = cli + if hasattr(cli, 'register_liugin_command'): + cli.register_liugin_command('dglab', self._cmd_handler) + + def _cmd_handler(self, args): + """处理 /dglab 命令""" + parts = args.strip().split(maxsplit=1) + if not parts: + return self.handle("help") + return self.handle(args) + + def get_tool_info(self): + return { + "name": "dglab_ws", + "description": "DG-Lab 郊狼 3.0 WebSocket 控制插件,支持连接、配对、强度控制、波形发送", + "keywords": [ + "dg-lab", "dglab", "郊狼", "coyote", "websocket", + "脉冲", "强度", "波形", "蓝牙", "控制", "情趣" + ], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "dglab_ws", + "description": "DG-Lab Coyote 3.0 WebSocket controller plugin", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "connect", "disconnect", "status", + "strength", "pulse", "wave", + "clear", "limit", "qr" + ] + }, + "args": {"type": "string", "description": "命令参数"} + }, + "required": ["action"] + } + } + + def convert_mcp_args(self, arguments): + action = arguments.get("action", "") + args = arguments.get("args", "") + return f"{action} {args}" + + # ── 核心协议方法 ────────────────────────────────────── + + def _on_message(self, ws, raw): + """处理服务端消息""" + try: + data = json.loads(raw) + except json.JSONDecodeError: + return + + msg_type = data.get("type", "") + msg = data.get("message", "") + cid = data.get("clientId", "") + tid = data.get("targetId", "") + + if msg_type == "bind": + if cid and not self.client_id: + self.client_id = cid + if msg == "200": + self.paired = True + self.target_id = tid + print(f"\n[DG-Lab] ✅ 配对成功! targetId={tid}") + elif msg == "400": + print("\n[DG-Lab] ❌ 配对失败: 该ID已被绑定") + else: + print(f"\n[DG-Lab] 绑定状态: {msg}") + + elif msg_type == "msg": + if msg.startswith("strength-"): + self._parse_strength(msg) + elif msg.startswith("feedback-"): + btn = msg.split("-")[1] + print(f"\n[DG-Lab] APP反馈按钮: {btn}") + + elif msg_type == "heartbeat": + pass + + elif msg_type == "break": + self.paired = False + print("\n[DG-Lab] ⚠️ APP端已断开连接") + + elif msg_type == "error": + print(f"\n[DG-Lab] ❌ 错误: {msg}") + + def _on_error(self, ws, error): + print(f"\n[DG-Lab] WebSocket错误: {error}") + + def _on_close(self, ws, code, reason): + self.connected = False + self.paired = False + self._running = False + print(f"\n[DG-Lab] 连接已关闭 (code={code}, reason={reason})") + + def _on_open(self, ws): + self.connected = True + print("\n[DG-Lab] ✅ 已连接到WebSocket服务器") + self._running = True + self._heartbeat_thread = threading.Thread( + target=self._heartbeat_loop, daemon=True + ) + self._heartbeat_thread.start() + + def _heartbeat_loop(self): + """每60秒发送心跳""" + while self._running and self.connected: + time.sleep(60) + if self.ws and self.connected: + try: + hb = { + "type": "heartbeat", + "clientId": self.client_id, + "targetId": self.target_id, + "message": "ping" + } + self.ws.send(json.dumps(hb)) + except Exception: + break + + def _parse_strength(self, msg): + """解析APP回传的强度: strength-A+B+A上限+B上限""" + try: + vals = msg.replace("strength-", "").split("+") + if len(vals) >= 4: + self.strength_a = int(vals[0]) + self.strength_b = int(vals[1]) + self.limit_a = int(vals[2]) + self.limit_b = int(vals[3]) + print( + f"\n[DG-Lab] 📊 强度状态: " + f"A={self.strength_a}/{self.limit_a} " + f"B={self.strength_b}/{self.limit_b}" + ) + except (ValueError, IndexError): + pass + + def _send_to_app(self, msg_type, message): + """发送消息到服务端 (转发给APP)""" + if not self.connected or not self.ws: + return False, "未连接到服务器" + if not self.paired: + return False, "尚未与APP配对,请先用APP扫码" + + payload = { + "type": msg_type, + "clientId": self.client_id, + "targetId": self.target_id, + "message": message + } + try: + self.ws.send(json.dumps(payload)) + return True, "ok" + except Exception as e: + return False, str(e) + + def _send_strength(self, channel, mode, value): + """ + 发送强度控制指令 + channel: 1=A, 2=B + mode: 0=减少, 1=增加, 2=设为指定值 + value: 0-200 + """ + msg = f"strength-{channel}+{mode}+{value}" + return self._send_to_app("msg", msg) + + def _send_pulse(self, channel, freq, intensity, duration=5): + """生成并发送波形数据""" + ch = "A" if channel.lower() == "a" else "B" + + def map_freq(f): + f = max(10, min(1000, f)) + if f <= 100: + return f + elif f <= 600: + return (f - 100) // 5 + 100 + else: + return (f - 600) // 10 + 200 + + freq_byte = map_freq(freq) + intensity_byte = max(0, min(100, intensity)) + + wave_hex = "" + for _ in range(25 * max(1, duration)): + wave_hex += f"{freq_byte:02X}{intensity_byte:02X}" * 4 + + pulse_data = [] + for i in range(0, len(wave_hex), 16): + chunk = wave_hex[i:i + 16] + if len(chunk) == 16: + pulse_data.append(chunk) + + if not pulse_data: + pulse_data = [f"{freq_byte:02X}" * 4 + f"{intensity_byte:02X}" * 4] + + batch_size = min(len(pulse_data), 100) + message = f"{ch}:{json.dumps(pulse_data[:batch_size])}" + + payload = { + "type": "clientMsg", + "channel": ch, + "time": duration, + "message": message, + "clientId": self.client_id, + "targetId": self.target_id + } + + if not self.connected or not self.ws: + return False, "未连接到服务器" + if not self.paired: + return False, "尚未与APP配对" + + try: + self.ws.send(json.dumps(payload)) + return True, f"已发送 {batch_size} 条波形数据到通道{ch},持续{duration}秒" + except Exception as e: + return False, str(e) + + def _send_raw_wave(self, channel, hex_data): + """发送原始HEX波形数据""" + ch = "A" if channel.lower() == "a" else "B" + pulse_data = [] + hex_clean = hex_data.replace(" ", "").replace(",", "") + for i in range(0, len(hex_clean), 16): + chunk = hex_clean[i:i + 16] + if len(chunk) == 16: + pulse_data.append(chunk) + + if not pulse_data: + return False, "波形数据不足8字节 (需要16个HEX字符)" + + message = f"{ch}:{json.dumps(pulse_data[:100])}" + payload = { + "type": "clientMsg", + "channel": ch, + "time": len(pulse_data) // 25 + 1, + "message": message, + "clientId": self.client_id, + "targetId": self.target_id + } + + if not self.connected or not self.ws: + return False, "未连接到服务器" + if not self.paired: + return False, "尚未与APP配对" + + try: + self.ws.send(json.dumps(payload)) + return True, f"已发送 {len(pulse_data)} 条波形数据到通道{ch}" + except Exception as e: + return False, str(e) + + # ── 命令入口 ────────────────────────────────────────── + + def handle(self, args): + """处理工具请求""" + parts = args.strip().split(maxsplit=1) + if not parts: + return self.usage + + action = parts[0].lower() + rest = parts[1] if len(parts) > 1 else "" + + if action == "help": + return self.usage + + elif action == "connect": + return self._do_connect(rest.strip()) + + elif action == "disconnect": + return self._do_disconnect() + + elif action == "qr": + return self._do_qr() + + elif action == "status": + return self._do_status() + + elif action == "strength": + return self._do_strength(rest) + + elif action == "pulse": + return self._do_pulse(rest) + + elif action == "wave": + return self._do_wave(rest) + + elif action == "clear": + return self._do_clear(rest) + + elif action == "limit": + return self._do_limit(rest) + + else: + return f"未知操作: {action}\n" + self.usage + + def _do_connect(self, url): + """连接WebSocket服务器""" + if not HAS_WS: + return "❌ 缺少 websocket-client 库,请运行: pip install websocket-client" + + if self.connected: + return f"已连接到 {self.ws_url},请先 disconnect" + + if not url: + return "请提供WebSocket地址,如: ws://192.168.1.100:9999" + + self.ws_url = url + self.client_id = str(uuid.uuid4()) + connect_url = f"{url}/{self.client_id}" + + try: + self.ws = websocket.WebSocketApp( + connect_url, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + on_open=self._on_open + ) + t = threading.Thread(target=self.ws.run_forever, daemon=True) + t.start() + + for _ in range(30): + if self.connected: + break + time.sleep(0.1) + + if self.connected: + return ( + f"✅ 已连接到 {url}\n" + f"clientId: {self.client_id}\n" + f"请使用 /dglab qr 生成二维码供APP扫码配对" + ) + else: + return f"❌ 连接超时,请检查地址是否正确: {url}" + except Exception as e: + return f"❌ 连接失败: {e}" + + def _do_disconnect(self): + """断开连接""" + if self.ws: + self._running = False + try: + self.ws.close() + except Exception: + pass + self.connected = False + self.paired = False + return "已断开连接" + return "当前没有连接" + + def _do_qr(self): + """生成配对二维码""" + if not self.connected or not self.client_id: + return "❌ 请先连接到WebSocket服务器" + + qr_content = ( + f"https://www.dungeon-lab.com/app-download.php" + f"#DGLAB-SOCKET#{self.ws_url}/{self.client_id}" + ) + + result = f"📱 配对二维码内容:\n{qr_content}\n\n" + result += "请用 DG-LAB APP 扫描此二维码进行配对\n" + + if HAS_QRCODE: + try: + qr = qrcode.QRCode(version=1, box_size=10, border=4) + qr.add_data(qr_content) + qr.make(fit=True) + qr.print_ascii(invert=True) + result += "\n(已在终端显示ASCII二维码)" + except Exception as e: + result += f"\n(二维码生成失败: {e})" + else: + result += "\n💡 安装 qrcode 库可在终端显示ASCII二维码: pip install qrcode" + + return result + + def _do_status(self): + """查看状态""" + lines = ["📊 DG-Lab 连接状态:"] + lines.append(f" 连接: {'✅ 已连接' if self.connected else '❌ 未连接'}") + lines.append(f" 配对: {'✅ 已配对' if self.paired else '❌ 未配对'}") + if self.connected: + lines.append(f" 服务器: {self.ws_url}") + lines.append(f" clientId: {self.client_id}") + if self.paired: + lines.append(f" targetId: {self.target_id}") + lines.append(f" 通道A强度: {self.strength_a}/{self.limit_a}") + lines.append(f" 通道B强度: {self.strength_b}/{self.limit_b}") + return "\n".join(lines) + + def _do_strength(self, args): + """控制强度: /dglab strength """ + parts = args.strip().split() + if len(parts) < 3: + return "格式: strength <+|-|=> <值>\n示例: strength a + 10" + + ch = parts[0].lower() + op = parts[1] + try: + val = int(parts[2]) + except ValueError: + return "强度值必须是整数" + + channel = 1 if ch == "a" else 2 + + if op == "+": + ok, msg = self._send_strength(channel, 1, val) + return f"{'✅' if ok else '❌'} 通道{ch.upper()} 强度+{val}" if ok else f"❌ {msg}" + elif op == "-": + ok, msg = self._send_strength(channel, 0, val) + return f"{'✅' if ok else '❌'} 通道{ch.upper()} 强度-{val}" if ok else f"❌ {msg}" + elif op in ("=", "set"): + val = max(0, min(200, val)) + ok, msg = self._send_strength(channel, 2, val) + return f"{'✅' if ok else '❌'} 通道{ch.upper()} 强度设为 {val}" if ok else f"❌ {msg}" + else: + return f"未知操作: {op},支持 +, -, =" + + def _do_pulse(self, args): + """发送波形: /dglab pulse """ + parts = args.strip().split() + if len(parts) < 3: + return "格式: pulse <频率10-240> <强度0-100> [持续秒数]" + + ch = parts[0].lower() + try: + freq = int(parts[1]) + intensity = int(parts[2]) + duration = int(parts[3]) if len(parts) > 3 else 5 + except ValueError: + return "参数必须是整数" + + ok, msg = self._send_pulse(ch, freq, intensity, duration) + return f"✅ {msg}" if ok else f"❌ {msg}" + + def _do_wave(self, args): + """发送原始波形HEX: /dglab wave """ + parts = args.strip().split(maxsplit=1) + if len(parts) < 2: + return "格式: wave \n示例: wave a 0A0A0A0A64646464" + + ch = parts[0].lower() + hex_data = parts[1].strip() + + ok, msg = self._send_raw_wave(ch, hex_data) + return f"✅ {msg}" if ok else f"❌ {msg}" + + def _do_clear(self, args): + """清空波形队列""" + ch = args.strip().lower() + if ch in ("a", "1"): + ok, msg = self._send_to_app("msg", "clear-1") + return "✅ 已清空通道A波形队列" if ok else f"❌ {msg}" + elif ch in ("b", "2"): + ok, msg = self._send_to_app("msg", "clear-2") + return "✅ 已清空通道B波形队列" if ok else f"❌ {msg}" + elif ch == "both": + self._send_to_app("msg", "clear-1") + self._send_to_app("msg", "clear-2") + return "✅ 已清空AB双通道波形队列" + else: + return "格式: clear " + + def _do_limit(self, args): + """设置软上限""" + parts = args.strip().split() + if len(parts) < 2: + return "格式: limit <0-200>" + + ch = parts[0].lower() + try: + val = max(0, min(200, int(parts[1]))) + except ValueError: + return "上限值必须是0-200的整数" + + if ch in ("a", "1"): + return ( + f"⚠️ 软上限需要通过蓝牙BF指令设置\n" + f" 当前通道A上限: {self.limit_a}\n" + f" 请在DG-LAB APP中调整软上限" + ) + elif ch in ("b", "2"): + return ( + f"⚠️ 软上限需要通过蓝牙BF指令设置\n" + f" 当前通道B上限: {self.limit_b}\n" + f" 请在DG-LAB APP中调整软上限" + ) + else: + return "格式: limit <0-200>"