xiaozhi-sdk 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

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.
xiaozhi_sdk/__init__.py CHANGED
@@ -1,222 +1,3 @@
1
- __version__ = "0.0.4"
1
+ __version__ = "0.0.6"
2
2
 
3
- import asyncio
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import uuid
9
- from collections import deque
10
- from typing import Any, Callable, Dict, Optional
11
-
12
- import websockets
13
-
14
- from xiaozhi_sdk.config import INPUT_SERVER_AUDIO_SAMPLE_RATE
15
- from xiaozhi_sdk.iot import OtaDevice
16
- from xiaozhi_sdk.mcp import McpTool
17
- from xiaozhi_sdk.utils import get_wav_info, read_audio_file, setup_opus
18
-
19
- setup_opus()
20
- from xiaozhi_sdk.opus import AudioOpus
21
-
22
- logger = logging.getLogger("xiaozhi_sdk")
23
-
24
-
25
- class XiaoZhiWebsocket(McpTool):
26
-
27
- def __init__(
28
- self,
29
- message_handler_callback: Optional[Callable] = None,
30
- url: Optional[str] = None,
31
- ota_url: Optional[str] = None,
32
- audio_sample_rate: int = 16000,
33
- audio_channels: int = 1,
34
- ):
35
- super().__init__()
36
- self.url = url
37
- self.ota_url = ota_url
38
- self.audio_channels = audio_channels
39
- self.audio_opus = AudioOpus(audio_sample_rate, audio_channels)
40
-
41
- # 客户端标识
42
- self.client_id = str(uuid.uuid4())
43
- self.mac_addr: Optional[str] = None
44
-
45
- # 回调函数
46
- self.message_handler_callback = message_handler_callback
47
-
48
- # 连接状态
49
- self.hello_received = asyncio.Event()
50
- self.session_id = ""
51
- self.websocket = None
52
- self.message_handler_task: Optional[asyncio.Task] = None
53
-
54
- # 输出音频
55
- self.output_audio_queue: deque[bytes] = deque()
56
-
57
- # OTA设备
58
- self.ota: Optional[OtaDevice] = None
59
-
60
- async def _send_hello(self, aec: bool) -> None:
61
- """发送hello消息"""
62
- hello_message = {
63
- "type": "hello",
64
- "version": 1,
65
- "features": {"aec": aec, "mcp": True},
66
- "transport": "websocket",
67
- "audio_params": {
68
- "format": "opus",
69
- "sample_rate": INPUT_SERVER_AUDIO_SAMPLE_RATE,
70
- "channels": 1,
71
- "frame_duration": 60,
72
- },
73
- }
74
- await self.websocket.send(json.dumps(hello_message))
75
- await asyncio.wait_for(self.hello_received.wait(), timeout=10.0)
76
-
77
- async def _start_listen(self) -> None:
78
- """开始监听"""
79
-
80
- listen_message = {"session_id": self.session_id, "type": "listen", "state": "start", "mode": "realtime"}
81
- await self.websocket.send(json.dumps(listen_message))
82
-
83
- async def _activate_iot_device(self, license_key: str, ota_info: Dict[str, Any]) -> None:
84
- """激活IoT设备"""
85
- if not ota_info.get("activation"):
86
- return
87
-
88
- if not self.ota:
89
- return
90
-
91
- await self._send_demo_audio()
92
- challenge = ota_info["activation"]["challenge"]
93
- await asyncio.sleep(3)
94
-
95
- for _ in range(10):
96
- if await self.ota.check_activate(challenge, license_key):
97
- break
98
- await asyncio.sleep(3)
99
-
100
- async def _send_demo_audio(self) -> None:
101
- """发送演示音频"""
102
- current_dir = os.path.dirname(os.path.abspath(__file__))
103
- wav_path = os.path.join(current_dir, "../file/audio/greet.wav")
104
- framerate, channels = get_wav_info(wav_path)
105
- audio_opus = AudioOpus(framerate, channels)
106
-
107
- for pcm_data in read_audio_file(wav_path):
108
- opus_data = await audio_opus.pcm_to_opus(pcm_data)
109
- await self.websocket.send(opus_data)
110
- await self.send_silence_audio()
111
-
112
- async def send_silence_audio(self, duration_seconds: float = 1.2) -> None:
113
- """发送静音音频"""
114
- frames_count = int(duration_seconds * 1000 / 60)
115
- pcm_frame = b"\x00\x00" * int(INPUT_SERVER_AUDIO_SAMPLE_RATE / 1000 * 60)
116
-
117
- for _ in range(frames_count):
118
- await self.send_audio(pcm_frame)
119
-
120
- async def _handle_websocket_message(self, message: Any) -> None:
121
- """处理接受到的WebSocket消息"""
122
-
123
- # audio data
124
- if isinstance(message, bytes):
125
- pcm_array = await self.audio_opus.opus_to_pcm(message)
126
- self.output_audio_queue.extend(pcm_array)
127
- return
128
-
129
- # json message
130
- data = json.loads(message)
131
- message_type = data["type"]
132
- if message_type == "hello":
133
- self.hello_received.set()
134
- self.session_id = data["session_id"]
135
- elif message_type == "mcp":
136
- await self.mcp(data)
137
- elif self.message_handler_callback:
138
- await self.message_handler_callback(data)
139
-
140
- async def _message_handler(self) -> None:
141
- """消息处理器"""
142
- try:
143
- async for message in self.websocket:
144
- await self._handle_websocket_message(message)
145
- except websockets.ConnectionClosed:
146
- if self.message_handler_callback:
147
- await self.message_handler_callback(
148
- {"type": "websocket", "state": "close", "source": "sdk.message_handler"}
149
- )
150
- logger.info("[websocket] close")
151
-
152
- async def set_mcp_tool_callback(self, tool_func: Dict[str, Callable[..., Any]]) -> None:
153
- """设置MCP工具回调函数"""
154
- self.tool_func = tool_func
155
-
156
- async def init_connection(
157
- self, mac_addr: str, aec: bool = False, serial_number: str = "", license_key: str = ""
158
- ) -> None:
159
- """初始化连接"""
160
- # 校验MAC地址格式 XX:XX:XX:XX:XX:XX
161
- mac_pattern = r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"
162
- if not re.match(mac_pattern, mac_addr):
163
- raise ValueError(f"无效的MAC地址格式: {mac_addr}。正确格式应为 XX:XX:XX:XX:XX:XX")
164
-
165
- self.mac_addr = mac_addr.lower()
166
-
167
- self.ota = OtaDevice(self.mac_addr, self.client_id, self.ota_url, serial_number)
168
- ota_info = await self.ota.activate_device()
169
- ws_url = ota_info["websocket"]["url"]
170
- self.url = self.url or ws_url
171
-
172
- if "tenclass.net" not in self.url and "xiaozhi.me" not in self.url:
173
- logger.warning("[websocket] 检测到非官方服务器,请谨慎使用!当前链接地址: %s", self.url)
174
-
175
- headers = {
176
- "Authorization": "Bearer {}".format(ota_info["websocket"]["token"]),
177
- "Protocol-Version": "1",
178
- "Device-Id": self.mac_addr,
179
- "Client-Id": self.client_id,
180
- }
181
- try:
182
- self.websocket = await websockets.connect(uri=self.url, additional_headers=headers)
183
- except websockets.exceptions.InvalidMessage as e:
184
- logger.error("[websocket] 连接失败,请检查网络连接或设备状态。当前链接地址: %s, 错误信息:%s", self.url, e)
185
- return
186
- self.message_handler_task = asyncio.create_task(self._message_handler())
187
-
188
- await self._send_hello(aec)
189
- await self._start_listen()
190
- asyncio.create_task(self._activate_iot_device(license_key, ota_info))
191
- await asyncio.sleep(0.5)
192
-
193
- async def send_audio(self, pcm: bytes) -> None:
194
- """发送音频数据"""
195
- if not self.websocket:
196
- return
197
-
198
- state = self.websocket.state
199
- if state == websockets.protocol.State.OPEN:
200
- opus_data = await self.audio_opus.pcm_to_opus(pcm)
201
- await self.websocket.send(opus_data)
202
- elif state in [websockets.protocol.State.CLOSED, websockets.protocol.State.CLOSING]:
203
- if self.message_handler_callback:
204
- await self.message_handler_callback({"type": "websocket", "state": "close", "source": "sdk.send_audio"})
205
- self.websocket = None
206
- logger.info("[websocket] close")
207
-
208
- await asyncio.sleep(0.5)
209
- else:
210
- await asyncio.sleep(0.1)
211
-
212
- async def close(self) -> None:
213
- """关闭连接"""
214
- if self.message_handler_task and not self.message_handler_task.done():
215
- self.message_handler_task.cancel()
216
- try:
217
- await self.message_handler_task
218
- except asyncio.CancelledError:
219
- pass
220
-
221
- if self.websocket:
222
- await self.websocket.close()
3
+ from xiaozhi_sdk.core import XiaoZhiWebsocket # noqa
xiaozhi_sdk/__main__.py CHANGED
@@ -1,123 +1,11 @@
1
- import argparse
2
- import asyncio
3
1
  import logging
4
- import time
5
- from collections import deque
6
- from typing import Optional
7
2
 
8
- import numpy as np
9
- import sounddevice as sd
3
+ from xiaozhi_sdk.cli import main
10
4
 
11
- from xiaozhi_sdk import XiaoZhiWebsocket
12
- from xiaozhi_sdk.config import INPUT_SERVER_AUDIO_SAMPLE_RATE
13
-
14
- # 配置logging
15
- logging.basicConfig(
16
- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
17
- )
18
5
  logger = logging.getLogger("xiaozhi_sdk")
19
6
 
20
- # 全局状态
21
- input_audio_buffer: deque[bytes] = deque()
22
- is_playing_audio = False
23
- is_end = False
24
-
25
-
26
- async def handle_message(message):
27
- """处理接收到的消息"""
28
- global is_end
29
- logger.info("message received: %s", message)
30
- if message["type"] == "websocket" and message["state"] == "close":
31
- is_end = True
32
-
33
-
34
- async def play_assistant_audio(audio_queue: deque[bytes]):
35
- """播放音频流"""
36
- global is_playing_audio
37
-
38
- stream = sd.OutputStream(samplerate=INPUT_SERVER_AUDIO_SAMPLE_RATE, channels=1, dtype=np.int16)
39
- stream.start()
40
- last_audio_time = None
41
-
42
- while True:
43
- if is_end:
44
- return
45
-
46
- if not audio_queue:
47
- await asyncio.sleep(0.01)
48
- if last_audio_time and time.time() - last_audio_time > 1:
49
- is_playing_audio = False
50
- continue
51
-
52
- is_playing_audio = True
53
- pcm_data = audio_queue.popleft()
54
- stream.write(pcm_data)
55
- last_audio_time = time.time()
56
-
57
-
58
- class XiaoZhiClient:
59
- """小智客户端类"""
60
-
61
- def __init__(
62
- self,
63
- url: Optional[str] = None,
64
- ota_url: Optional[str] = None,
65
- ):
66
- self.xiaozhi: Optional[XiaoZhiWebsocket] = None
67
- self.url = url
68
- self.ota_url = ota_url
69
-
70
- async def start(self, mac_address: str, serial_number: str = "", license_key: str = ""):
71
- """启动客户端连接"""
72
- self.mac_address = mac_address
73
- self.xiaozhi = XiaoZhiWebsocket(handle_message, url=self.url, ota_url=self.ota_url)
74
- await self.xiaozhi.init_connection(
75
- self.mac_address, aec=False, serial_number=serial_number, license_key=license_key
76
- )
77
- asyncio.create_task(play_assistant_audio(self.xiaozhi.output_audio_queue))
78
-
79
- def audio_callback(self, indata, frames, time, status):
80
- """音频输入回调函数"""
81
- pcm_data = (indata.flatten() * 32767).astype(np.int16).tobytes()
82
- input_audio_buffer.append(pcm_data)
83
-
84
- async def process_audio_input(self):
85
- """处理音频输入"""
86
- while True:
87
-
88
- if is_end:
89
- return
90
-
91
- if not input_audio_buffer:
92
- await asyncio.sleep(0.02)
93
- continue
94
-
95
- pcm_data = input_audio_buffer.popleft()
96
- if not is_playing_audio:
97
- await self.xiaozhi.send_audio(pcm_data)
98
-
99
-
100
- async def main():
101
- """主函数"""
102
- parser = argparse.ArgumentParser(description="小智SDK客户端")
103
- parser.add_argument("device", help="设备的MAC地址 (格式: XX:XX:XX:XX:XX:XX)")
104
- parser.add_argument("--url", help="服务端websocket地址")
105
- parser.add_argument("--ota_url", help="OTA地址")
106
-
107
- parser.add_argument("--serial_number", default="", help="设备的序列号")
108
- parser.add_argument("--license_key", default="", help="设备的授权密钥")
109
-
110
- args = parser.parse_args()
111
- logger.info("Recording... Press Ctrl+C to stop.")
112
- client = XiaoZhiClient(args.url, args.ota_url)
113
- await client.start(args.device, args.serial_number, args.license_key)
114
-
115
- with sd.InputStream(callback=client.audio_callback, channels=1, samplerate=16000, blocksize=960):
116
- await client.process_audio_input()
117
-
118
-
119
7
  if __name__ == "__main__":
120
8
  try:
121
- asyncio.run(main())
9
+ main()
122
10
  except KeyboardInterrupt:
123
- logger.info("Stopping...")
11
+ logger.debug("Stopping...")
xiaozhi_sdk/cli.py ADDED
@@ -0,0 +1,137 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections import deque
5
+ from typing import Optional
6
+
7
+ import click
8
+ import colorlog
9
+ import numpy as np
10
+ import sounddevice as sd
11
+
12
+ from xiaozhi_sdk import XiaoZhiWebsocket
13
+ from xiaozhi_sdk.config import INPUT_SERVER_AUDIO_SAMPLE_RATE
14
+
15
+ # 配置彩色logging
16
+ handler = colorlog.StreamHandler()
17
+ handler.setFormatter(
18
+ colorlog.ColoredFormatter(
19
+ "%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s",
20
+ datefmt="%Y-%m-%d %H:%M:%S",
21
+ log_colors={
22
+ "DEBUG": "green",
23
+ "INFO": "white",
24
+ "WARNING": "yellow",
25
+ "ERROR": "red",
26
+ "CRITICAL": "red,bg_white",
27
+ },
28
+ )
29
+ )
30
+
31
+ logger = logging.getLogger("xiaozhi_sdk")
32
+ logger.addHandler(handler)
33
+ logger.setLevel(logging.DEBUG)
34
+
35
+ # 全局状态
36
+ input_audio_buffer: deque[bytes] = deque()
37
+ is_playing_audio = False
38
+ is_end = False
39
+
40
+
41
+ async def handle_message(message):
42
+ """处理接收到的消息"""
43
+ global is_end
44
+ logger.info("message received: %s", message)
45
+ if message["type"] == "websocket" and message["state"] == "close":
46
+ is_end = True
47
+
48
+
49
+ async def play_assistant_audio(audio_queue: deque[bytes]):
50
+ """播放音频流"""
51
+ global is_playing_audio
52
+
53
+ stream = sd.OutputStream(samplerate=INPUT_SERVER_AUDIO_SAMPLE_RATE, channels=1, dtype=np.int16)
54
+ stream.start()
55
+ last_audio_time = None
56
+
57
+ while True:
58
+ if is_end:
59
+ return
60
+
61
+ if not audio_queue:
62
+ await asyncio.sleep(0.01)
63
+ if last_audio_time and time.time() - last_audio_time > 1:
64
+ is_playing_audio = False
65
+ continue
66
+
67
+ is_playing_audio = True
68
+ pcm_data = audio_queue.popleft()
69
+ stream.write(pcm_data)
70
+ last_audio_time = time.time()
71
+
72
+
73
+ class XiaoZhiClient:
74
+ """小智客户端类"""
75
+
76
+ def __init__(
77
+ self,
78
+ url: Optional[str] = None,
79
+ ota_url: Optional[str] = None,
80
+ ):
81
+ self.xiaozhi: Optional[XiaoZhiWebsocket] = None
82
+ self.url = url
83
+ self.ota_url = ota_url
84
+ self.mac_address = ""
85
+
86
+ async def start(self, mac_address: str, serial_number: str = "", license_key: str = ""):
87
+ """启动客户端连接"""
88
+ self.mac_address = mac_address
89
+ self.xiaozhi = XiaoZhiWebsocket(handle_message, url=self.url, ota_url=self.ota_url, send_wake=True)
90
+ await self.xiaozhi.init_connection(
91
+ self.mac_address, aec=False, serial_number=serial_number, license_key=license_key
92
+ )
93
+ asyncio.create_task(play_assistant_audio(self.xiaozhi.output_audio_queue))
94
+
95
+ def audio_callback(self, indata, frames, time, status):
96
+ """音频输入回调函数"""
97
+ pcm_data = (indata.flatten() * 32767).astype(np.int16).tobytes()
98
+ input_audio_buffer.append(pcm_data)
99
+
100
+ async def process_audio_input(self):
101
+ """处理音频输入"""
102
+ while True:
103
+
104
+ if is_end:
105
+ return
106
+
107
+ if not input_audio_buffer:
108
+ await asyncio.sleep(0.02)
109
+ continue
110
+
111
+ pcm_data = input_audio_buffer.popleft()
112
+ if not is_playing_audio:
113
+ await self.xiaozhi.send_audio(pcm_data)
114
+
115
+
116
+ async def run_client(mac_address: str, url: str, ota_url: str, serial_number: str, license_key: str):
117
+ """运行客户端的异步函数"""
118
+ logger.debug("Recording... Press Ctrl+C to stop.")
119
+ client = XiaoZhiClient(url, ota_url)
120
+ await client.start(mac_address, serial_number, license_key)
121
+
122
+ with sd.InputStream(callback=client.audio_callback, channels=1, samplerate=16000, blocksize=960):
123
+ await client.process_audio_input()
124
+
125
+
126
+ @click.command()
127
+ @click.argument("mac_address")
128
+ @click.option("--url", help="服务端websocket地址")
129
+ @click.option("--ota_url", help="OTA地址")
130
+ @click.option("--serial_number", default="", help="设备的序列号")
131
+ @click.option("--license_key", default="", help="设备的授权密钥")
132
+ def main(mac_address: str, url: str, ota_url: str, serial_number: str, license_key: str):
133
+ """小智SDK客户端
134
+
135
+ MAC_ADDRESS: 设备的MAC地址 (格式: XX:XX:XX:XX:XX:XX)
136
+ """
137
+ asyncio.run(run_client(mac_address, url, ota_url, serial_number, license_key))
xiaozhi_sdk/config.py CHANGED
@@ -1,3 +1,3 @@
1
1
  INPUT_SERVER_AUDIO_SAMPLE_RATE = 16000
2
2
 
3
- OTA_URL = "https://api.tenclass.net/xiaozhi/ota/"
3
+ OTA_URL = "https://api.tenclass.net/xiaozhi/ota"
xiaozhi_sdk/core.py ADDED
@@ -0,0 +1,257 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+ import uuid
7
+ from collections import deque
8
+ from typing import Any, Callable, Dict, Optional
9
+
10
+ import websockets
11
+
12
+ from xiaozhi_sdk.config import INPUT_SERVER_AUDIO_SAMPLE_RATE
13
+ from xiaozhi_sdk.iot import OtaDevice
14
+ from xiaozhi_sdk.mcp import McpTool
15
+ from xiaozhi_sdk.utils import get_wav_info, read_audio_file, setup_opus
16
+
17
+ setup_opus()
18
+ from xiaozhi_sdk.opus import AudioOpus
19
+
20
+ logger = logging.getLogger("xiaozhi_sdk")
21
+
22
+
23
+ class XiaoZhiWebsocket(McpTool):
24
+
25
+ def __init__(
26
+ self,
27
+ message_handler_callback: Optional[Callable] = None,
28
+ url: Optional[str] = None,
29
+ ota_url: Optional[str] = None,
30
+ audio_sample_rate: int = 16000,
31
+ audio_channels: int = 1,
32
+ send_wake: bool = False,
33
+ ):
34
+ super().__init__()
35
+ self.url = url
36
+ self.ota_url = ota_url
37
+ self.send_wake = send_wake
38
+ self.audio_channels = audio_channels
39
+ self.audio_opus = AudioOpus(audio_sample_rate, audio_channels)
40
+
41
+ # 客户端标识
42
+ self.client_id = str(uuid.uuid4())
43
+ self.mac_addr: Optional[str] = None
44
+ self.aec = False
45
+ self.websocket_token = ""
46
+
47
+ # 回调函数
48
+ self.message_handler_callback = message_handler_callback
49
+
50
+ # 连接状态
51
+ self.hello_received = asyncio.Event()
52
+ self.session_id = ""
53
+ self.websocket = None
54
+ self.message_handler_task: Optional[asyncio.Task] = None
55
+
56
+ # 输出音频
57
+ self.output_audio_queue: deque[bytes] = deque()
58
+
59
+ # OTA设备
60
+ self.ota: Optional[OtaDevice] = None
61
+ self.iot_task: Optional[asyncio.Task] = None
62
+ self.wait_device_activated: bool = False
63
+
64
+ async def _send_hello(self, aec: bool) -> None:
65
+ """发送hello消息"""
66
+ hello_message = {
67
+ "type": "hello",
68
+ "version": 1,
69
+ "features": {"mcp": True, "aec": aec},
70
+ "transport": "websocket",
71
+ "audio_params": {
72
+ "format": "opus",
73
+ "sample_rate": 16000,
74
+ "channels": 1,
75
+ "frame_duration": 60,
76
+ },
77
+ }
78
+ await self.websocket.send(json.dumps(hello_message))
79
+ await asyncio.wait_for(self.hello_received.wait(), timeout=10.0)
80
+
81
+ async def _start_listen(self) -> None:
82
+ """开始监听"""
83
+ listen_message = {"session_id": self.session_id, "type": "listen", "state": "start", "mode": "realtime"}
84
+ await self.websocket.send(json.dumps(listen_message))
85
+
86
+ async def is_activate(self, ota_info):
87
+ """是否激活"""
88
+ if ota_info.get("activation"):
89
+ return False
90
+
91
+ return True
92
+
93
+ async def _activate_iot_device(self, license_key: str, ota_info: Dict[str, Any]) -> None:
94
+ """激活IoT设备"""
95
+ if not self.ota:
96
+ return
97
+
98
+ challenge = ota_info["activation"]["challenge"]
99
+ await asyncio.sleep(3)
100
+ self.wait_device_activated = True
101
+ for _ in range(10):
102
+ if await self.ota.check_activate(challenge, license_key):
103
+ self.wait_device_activated = False
104
+ break
105
+ await asyncio.sleep(3)
106
+
107
+ async def _send_demo_audio(self) -> None:
108
+ """发送演示音频"""
109
+ current_dir = os.path.dirname(os.path.abspath(__file__))
110
+ wav_path = os.path.join(current_dir, "../file/audio/greet.wav")
111
+ framerate, channels = get_wav_info(wav_path)
112
+ audio_opus = AudioOpus(framerate, channels)
113
+
114
+ for pcm_data in read_audio_file(wav_path):
115
+ opus_data = await audio_opus.pcm_to_opus(pcm_data)
116
+ await self.websocket.send(opus_data)
117
+ await self.send_silence_audio()
118
+
119
+ async def send_wake_word(self, wake_word: str = "你好,小智") -> None:
120
+ """发送唤醒词"""
121
+ await self.websocket.send(
122
+ json.dumps({"session_id": self.session_id, "type": "listen", "state": "detect", "text": wake_word})
123
+ )
124
+
125
+ async def send_silence_audio(self, duration_seconds: float = 1.2) -> None:
126
+ """发送静音音频"""
127
+ frames_count = int(duration_seconds * 1000 / 60)
128
+ pcm_frame = b"\x00\x00" * int(INPUT_SERVER_AUDIO_SAMPLE_RATE / 1000 * 60)
129
+
130
+ for _ in range(frames_count):
131
+ await self.send_audio(pcm_frame)
132
+
133
+ async def _handle_websocket_message(self, message: Any) -> None:
134
+ """处理接受到的WebSocket消息"""
135
+
136
+ # audio data
137
+ if isinstance(message, bytes):
138
+ pcm_array = await self.audio_opus.opus_to_pcm(message)
139
+ self.output_audio_queue.extend(pcm_array)
140
+ return
141
+
142
+ # json message
143
+ data = json.loads(message)
144
+ message_type = data["type"]
145
+ if message_type == "hello":
146
+ self.hello_received.set()
147
+ self.session_id = data["session_id"]
148
+ elif message_type == "mcp":
149
+ await self.mcp(data)
150
+ elif self.message_handler_callback:
151
+ await self.message_handler_callback(data)
152
+
153
+ async def _message_handler(self) -> None:
154
+ """消息处理器"""
155
+ try:
156
+ async for message in self.websocket:
157
+ await self._handle_websocket_message(message)
158
+ except websockets.ConnectionClosed:
159
+ if self.message_handler_callback:
160
+ await self.message_handler_callback(
161
+ {"type": "websocket", "state": "close", "source": "sdk.message_handler"}
162
+ )
163
+ logger.debug("[websocket] close")
164
+
165
+ async def set_mcp_tool_callback(self, tool_func: Dict[str, Callable[..., Any]]) -> None:
166
+ """设置MCP工具回调函数"""
167
+ self.tool_func = tool_func
168
+
169
+ async def connect_websocket(self, websocket_token):
170
+ """连接websocket"""
171
+ headers = {
172
+ "Authorization": "Bearer {}".format(websocket_token),
173
+ "Protocol-Version": "1",
174
+ "Device-Id": self.mac_addr,
175
+ "Client-Id": self.client_id,
176
+ }
177
+ try:
178
+ self.websocket = await websockets.connect(uri=self.url, additional_headers=headers)
179
+ except websockets.exceptions.InvalidMessage as e:
180
+ logger.error("[websocket] 连接失败,请检查网络连接或设备状态。当前链接地址: %s, 错误信息:%s", self.url, e)
181
+ return
182
+ self.message_handler_task = asyncio.create_task(self._message_handler())
183
+
184
+ await self._send_hello(self.aec)
185
+ await self._start_listen()
186
+ logger.debug("[websocket] Connection successful")
187
+ await asyncio.sleep(0.5)
188
+
189
+ async def init_connection(
190
+ self, mac_addr: str, aec: bool = False, serial_number: str = "", license_key: str = ""
191
+ ) -> None:
192
+ """初始化连接"""
193
+ mac_pattern = r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"
194
+ if not re.match(mac_pattern, mac_addr):
195
+ raise ValueError(f"无效的MAC地址格式: {mac_addr}。正确格式应为 XX:XX:XX:XX:XX:XX")
196
+
197
+ self.mac_addr = mac_addr.lower()
198
+ self.aec = aec
199
+
200
+ self.ota = OtaDevice(self.mac_addr, self.client_id, self.ota_url, serial_number)
201
+ ota_info = await self.ota.activate_device()
202
+ ws_url = ota_info.get("websocket", {}).get("url")
203
+ self.url = self.url or ws_url
204
+
205
+ if not self.url:
206
+ logger.warning("[websocket] 未找到websocket链接地址")
207
+ return
208
+
209
+ if "tenclass.net" not in self.url and "xiaozhi.me" not in self.url:
210
+ logger.warning("[websocket] 检测到非官方服务器,当前链接地址: %s", self.url)
211
+
212
+ self.websocket_token = ota_info["websocket"]["token"]
213
+ await self.connect_websocket(self.websocket_token)
214
+
215
+ if not await self.is_activate(ota_info):
216
+ self.iot_task = asyncio.create_task(self._activate_iot_device(license_key, ota_info))
217
+ logger.debug("[IOT] 设备未激活")
218
+
219
+ if self.send_wake:
220
+ await self.send_wake_word()
221
+
222
+ async def send_audio(self, pcm: bytes) -> None:
223
+ """发送音频数据"""
224
+ if not self.websocket:
225
+ return
226
+
227
+ state = self.websocket.state
228
+ if state == websockets.protocol.State.OPEN:
229
+ opus_data = await self.audio_opus.pcm_to_opus(pcm)
230
+ await self.websocket.send(opus_data)
231
+ elif state in [websockets.protocol.State.CLOSED, websockets.protocol.State.CLOSING]:
232
+ if self.wait_device_activated:
233
+ logger.debug("[websocket] Server actively disconnected, reconnecting...")
234
+ await self.connect_websocket(self.websocket_token)
235
+ elif self.message_handler_callback:
236
+ await self.message_handler_callback({"type": "websocket", "state": "close", "source": "sdk.send_audio"})
237
+ self.websocket = None
238
+ logger.debug("[websocket] Server actively disconnected")
239
+
240
+ await asyncio.sleep(0.5)
241
+ else:
242
+ await asyncio.sleep(0.1)
243
+
244
+ async def close(self) -> None:
245
+ """关闭连接"""
246
+ if self.message_handler_task and not self.message_handler_task.done():
247
+ self.message_handler_task.cancel()
248
+ try:
249
+ await self.message_handler_task
250
+ except asyncio.CancelledError:
251
+ pass
252
+
253
+ if self.iot_task:
254
+ self.iot_task.cancel()
255
+
256
+ if self.websocket:
257
+ await self.websocket.close()
xiaozhi_sdk/iot.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import hashlib
2
2
  import hmac
3
3
  import json
4
+ import logging
4
5
  from typing import Any, Dict, Optional
5
6
 
6
7
  import aiohttp
@@ -13,6 +14,8 @@ BOARD_TYPE = "xiaozhi-sdk-box"
13
14
  USER_AGENT = "xiaozhi-sdk/{}".format(__version__)
14
15
  BOARD_NAME = "xiaozhi-sdk-{}".format(__version__)
15
16
 
17
+ logger = logging.getLogger("xiaozhi_sdk")
18
+
16
19
 
17
20
  class OtaDevice:
18
21
  """
@@ -56,7 +59,7 @@ class OtaDevice:
56
59
  }
57
60
 
58
61
  async with aiohttp.ClientSession() as session:
59
- async with session.post(self.ota_url, headers=headers, data=json.dumps(payload)) as response:
62
+ async with session.post(self.ota_url + "/", headers=headers, data=json.dumps(payload)) as response:
60
63
  response.raise_for_status()
61
64
  return await response.json()
62
65
 
@@ -72,4 +75,7 @@ class OtaDevice:
72
75
 
73
76
  async with aiohttp.ClientSession() as session:
74
77
  async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
75
- return response.status == 200
78
+ is_ok = response.status == 200
79
+ if not is_ok:
80
+ logger.debug("[IOT] wait for activate device...")
81
+ return is_ok
xiaozhi_sdk/mcp.py CHANGED
@@ -87,7 +87,7 @@ class McpTool(object):
87
87
  mcp_tool_conf[name]["name"] = name
88
88
  mcp_tools_payload["result"]["tools"].append(mcp_tool_conf[name])
89
89
  await self.websocket.send(self.get_mcp_json(mcp_tools_payload))
90
- logger.info("[MCP] 加载成功,当前可用工具列表为:%s", tool_list)
90
+ logger.debug("[MCP] 加载成功,当前可用工具列表为:%s", tool_list)
91
91
 
92
92
  elif method == "tools/call":
93
93
  tool_name = payload["params"]["name"]
@@ -97,6 +97,6 @@ class McpTool(object):
97
97
 
98
98
  mcp_res = await self.mcp_tool_call(payload)
99
99
  await self.websocket.send(mcp_res)
100
- logger.info("[MCP] Tool %s called", tool_name)
100
+ logger.debug("[MCP] Tool %s called", tool_name)
101
101
  else:
102
102
  logger.warning("[MCP] unknown method %s: %s", method, payload)
xiaozhi_sdk/opus.py CHANGED
@@ -2,7 +2,7 @@ import av
2
2
  import numpy as np
3
3
  import opuslib
4
4
 
5
- from xiaozhi_sdk import INPUT_SERVER_AUDIO_SAMPLE_RATE
5
+ from xiaozhi_sdk.config import INPUT_SERVER_AUDIO_SAMPLE_RATE
6
6
 
7
7
 
8
8
  class AudioOpus:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xiaozhi-sdk
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: 一个用于连接和控制小智智能设备的Python SDK,支持实时音频通信、MCP工具集成和设备管理功能。
5
5
  Author-email: dairoot <623815825@qq.com>
6
6
  License: MIT
@@ -12,13 +12,15 @@ Requires-Python: >=3.8.1
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: numpy
15
- Requires-Dist: websockets
15
+ Requires-Dist: websockets>=15.0.1
16
16
  Requires-Dist: aiohttp
17
17
  Requires-Dist: av
18
18
  Requires-Dist: opuslib
19
19
  Requires-Dist: requests
20
20
  Requires-Dist: sounddevice
21
21
  Requires-Dist: python-socks
22
+ Requires-Dist: click
23
+ Requires-Dist: colorlog
22
24
  Dynamic: license-file
23
25
 
24
26
  # 小智SDK (XiaoZhi SDK)
@@ -53,21 +55,7 @@ pip install xiaozhi-sdk
53
55
  #### 查看帮助信息
54
56
 
55
57
  ```bash
56
- python -m xiaozhi_sdk -h
57
- ```
58
-
59
- 输出示例:
60
- ```text
61
- positional arguments:
62
- device 你的小智设备的MAC地址 (格式: XX:XX:XX:XX:XX:XX)
63
-
64
- options:
65
- -h, --help show this help message and exit
66
- --url URL 服务端websocket地址
67
- --ota_url OTA_URL OTA地址
68
- --serial_number SERIAL_NUMBER 设备的序列号
69
- --license_key LICENSE_KEY 设备的授权密钥
70
-
58
+ python -m xiaozhi_sdk --help
71
59
  ```
72
60
 
73
61
  #### 连接设备(需要提供 MAC 地址)
@@ -76,11 +64,13 @@ options:
76
64
  python -m xiaozhi_sdk 00:22:44:66:88:00
77
65
  ```
78
66
 
79
- ### 2. 编程使用
67
+ ### 2. 编程使用 (高阶用法)
80
68
  参考 [examples](examples/) 文件中的示例代码,可以快速开始使用 SDK。
81
69
 
82
70
 
83
- ### 运行测试
71
+ ---
72
+
73
+ ## ✅ 运行测试
84
74
 
85
75
  ```bash
86
76
  pytest tests/
@@ -7,16 +7,18 @@ file/opus/linux-x64-libopus.so,sha256=FmXJqkxLpDzNFOHYkmOzmsp1hP0eIS5b6x_XfOs-IQ
7
7
  file/opus/macos-arm64-libopus.dylib,sha256=H7wXwkrGwb-hesMMZGFxWb0Ri1Y4m5GWiKsd8CfOhE8,357584
8
8
  file/opus/macos-x64-libopus.dylib,sha256=MqyL_OjwSACF4Xs_-KrGbcScy4IEprr5Rlkk3ddZye8,550856
9
9
  file/opus/windows-x86_64-opus.dll,sha256=kLfhioMvbJhOgNMAldpWk3DCZqC5Xd70LRbHnACvAnw,463360
10
- xiaozhi_sdk/__init__.py,sha256=iNRkEIYK6f4kbbu3dz874LKOxtMitUa2GzHH2gRSD1w,8034
11
- xiaozhi_sdk/__main__.py,sha256=_Xh6v2oMYXYHsrAkw4PYMJpvi-0r3ujLNRLMxPNarTQ,3807
12
- xiaozhi_sdk/config.py,sha256=mpjWWklTI2bw4zY3ZWCYvqvpfZSoF5iM7ubAP9y_8cM,90
10
+ xiaozhi_sdk/__init__.py,sha256=byRv-MwEkq9DRNCv9xwhLSqCfHjgMHDa2BizaN55CPo,77
11
+ xiaozhi_sdk/__main__.py,sha256=i0ZJdHUqAKg9vwZrK_w0TJkzdotTYTK8aUeSPcJc1ks,210
12
+ xiaozhi_sdk/cli.py,sha256=0Hq-wPUv5Hfjzn7pVcCNMe3cbKTwwJytDhXSYivFOt8,4239
13
+ xiaozhi_sdk/config.py,sha256=h4mpMeBf2vT9qYAqCCbGVGmMemkgk98pcXP2Rh4TEFc,89
14
+ xiaozhi_sdk/core.py,sha256=a_-JnLMQgT93O9GMytUNLLot8N3dZbNjan-tsC7GwrY,9447
13
15
  xiaozhi_sdk/data.py,sha256=8z8erOjBZFvPSBJlPoyTzRYZ3BuMvnPpAFQCbSxs-48,2522
14
- xiaozhi_sdk/iot.py,sha256=aIrHVZVVObu8VDC20JOc8yO4X7ORMW-R7RNxIEyl9ng,2386
15
- xiaozhi_sdk/mcp.py,sha256=JA-z6EjGqitEfwMlvxk6XUSjbmfAdyWJVZPjtjqo6Oo,3823
16
- xiaozhi_sdk/opus.py,sha256=4O-kz-PcUVmpa27Vju6jv-sbwywuAXFvVL23R1-vv5o,2104
16
+ xiaozhi_sdk/iot.py,sha256=IO3SfiuQxucYl_917BCNCwIAv1dajCJI-IFTWwHnSDE,2580
17
+ xiaozhi_sdk/mcp.py,sha256=Q_htzBMunj3-9wARONeCFPTbApQicHWRbG6BlX4oQss,3825
18
+ xiaozhi_sdk/opus.py,sha256=BX5BZATjWSUGbh1z-GjZhIKmZezHPADFYcIpoIsTtRQ,2111
17
19
  xiaozhi_sdk/utils.py,sha256=5qHAiI5Nrzeka3TofMPhAVmMovEJJa6QSrKcDM0OF4g,1703
18
- xiaozhi_sdk-0.0.4.dist-info/licenses/LICENSE,sha256=Vwgps1iODKl43cAtME_0dawTjAzNW-O2BWiN5BHggww,1085
19
- xiaozhi_sdk-0.0.4.dist-info/METADATA,sha256=WdiQkFfDXnlRiFHZT3Z7lq9d4t94J1imAH6ai_OIuhs,2344
20
- xiaozhi_sdk-0.0.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
- xiaozhi_sdk-0.0.4.dist-info/top_level.txt,sha256=nBpue4hU5Ykm5CtYPsAdxSa_yqbtZsIT_gF_EkBaJPM,12
22
- xiaozhi_sdk-0.0.4.dist-info/RECORD,,
20
+ xiaozhi_sdk-0.0.6.dist-info/licenses/LICENSE,sha256=Vwgps1iODKl43cAtME_0dawTjAzNW-O2BWiN5BHggww,1085
21
+ xiaozhi_sdk-0.0.6.dist-info/METADATA,sha256=0Mum7x2Ul3YhOeMqk85Rn6SySUVdf2TDfTsyRoQphTM,2014
22
+ xiaozhi_sdk-0.0.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
+ xiaozhi_sdk-0.0.6.dist-info/top_level.txt,sha256=nBpue4hU5Ykm5CtYPsAdxSa_yqbtZsIT_gF_EkBaJPM,12
24
+ xiaozhi_sdk-0.0.6.dist-info/RECORD,,