diff --git a/README.md b/README.md index 121dfbe6..056c6678 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,13 @@ server: 支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。 - **记忆功能** 支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。 +- **IOT功能** + 支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制。 ### 正在开发 🚧 - 多种心情模式 - 智控台webui -- iot功能 想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3) diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 21f37c5b..689b93fb 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -35,10 +35,7 @@ log: log_file: "server.log" # 设置数据文件路径 data_dir: data -iot: - Speaker: - # 设置esp32的音量,范围0-100 - volume: 80 + xiaozhi: type: hello version: 1 diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index adb52c1f..2ea7ac06 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -458,7 +458,7 @@ class ConnectionHandler: future = self.executor.submit(self.speak_and_play, text, text_index) self.tts_queue.put(future) self.dialogue.put(Message(role="assistant", content=text)) - if result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 + elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 text = result.result if text is not None and len(text) > 0: @@ -473,9 +473,10 @@ class ConnectionHandler: self.dialogue.put(Message(role="tool", tool_call_id=function_id, content=text)) self.chat_with_function_calling(text, tool_call=True) - if result.action == Action.NOTFOUND: + elif result.action == Action.NOTFOUND: + text = result.response + else: text = result.response - def _tts_priority_thread(self): diff --git a/main/xiaozhi-server/core/handle/functionHandler.py b/main/xiaozhi-server/core/handle/functionHandler.py index 8d54fe8a..c7449973 100644 --- a/main/xiaozhi-server/core/handle/functionHandler.py +++ b/main/xiaozhi-server/core/handle/functionHandler.py @@ -50,6 +50,7 @@ class FunctionHandler: self.function_registry.register_function("play_music") self.function_registry.register_function("plugin_loader") self.function_registry.register_function("get_time") + self.function_registry.register_function("raise_and_lower_the_volume") def register_config_functions(self): """注册配置中的函数,可以不同客户端使用不同的配置""" @@ -69,7 +70,7 @@ class FunctionHandler: arguments = function_call_data["arguments"] arguments = json.loads(arguments) if arguments else {} logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}") - if funcItem.type == ToolType.SYSTEM_CTL: + if funcItem.type == ToolType.SYSTEM_CTL or funcItem.type == ToolType.IOT_CTL: return func(conn, **arguments) elif funcItem.type == ToolType.WAIT: return func(**arguments) diff --git a/main/xiaozhi-server/core/handle/iotHandle.py b/main/xiaozhi-server/core/handle/iotHandle.py index 05d32d5a..9def78cd 100644 --- a/main/xiaozhi-server/core/handle/iotHandle.py +++ b/main/xiaozhi-server/core/handle/iotHandle.py @@ -1,24 +1,151 @@ import json +import asyncio from config.logger import setup_logging +from plugins_func.register import device_type_registry, register_function, ActionResponse, Action, ToolType TAG = __name__ logger = setup_logging() +def wrap_async_function(async_func): + """包装异步函数为同步函数""" + + def wrapper(*args, **kwargs): + try: + # 获取连接对象(第一个参数) + conn = args[0] + if not hasattr(conn, 'loop'): + logger.bind(tag=TAG).error("Connection对象没有loop属性") + return ActionResponse(Action.ERROR, "Connection对象没有loop属性", + "执行操作时出错: Connection对象没有loop属性") + + # 使用conn对象中的事件循环 + loop = conn.loop + # 在conn的事件循环中运行异步函数 + future = asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), loop) + # 等待结果返回 + return future.result() + except Exception as e: + logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}") + return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}") + + return wrapper + + +def create_iot_function(device_name, method_name, method_info): + """ + 根据IOT设备描述生成通用的控制函数 + """ + + async def iot_control_function(conn, response_success=None, response_failure=None, **params): + try: + # 打印响应参数 + logger.bind(tag=TAG).info( + f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'") + + # 发送控制命令 + await send_iot_conn(conn, device_name, method_name, params) + # 等待一小段时间让状态更新 + await asyncio.sleep(0.1) + + # 生成结果信息 + result = f"{device_name}的{method_name}操作执行成功" + + # 根据方法名尝试自动更新状态 + await update_state_by_method(conn, device_name, method_name, params) + + # 处理响应中可能的占位符 + response = response_success + # 替换{value}占位符 + for param_name, param_value in params.items(): + # 先尝试直接替换参数值 + if "{" + param_name + "}" in response: + response = response.replace("{" + param_name + "}", str(param_value)) + + # 如果有{value}占位符,用相关参数替换 + if "{value}" in response: + response = response.replace("{value}", str(param_value)) + break + + return ActionResponse(Action.RESPONSE, result, response) + except Exception as e: + logger.bind(tag=TAG).error(f"执行{device_name}的{method_name}操作失败: {e}") + + # 操作失败时使用大模型提供的失败响应 + response = response_failure + + return ActionResponse(Action.ERROR, str(e), response) + + return wrap_async_function(iot_control_function) + + +def create_iot_query_function(device_name, prop_name, prop_info): + """ + 根据IOT设备属性创建查询函数 + """ + + async def iot_query_function(conn, response_success=None, response_failure=None): + try: + # 打印响应参数 + logger.bind(tag=TAG).info( + f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'") + + value = await get_iot_status(conn, device_name, prop_name) + + # 查询成功,生成结果 + if value is not None: + # 使用大模型提供的成功响应,并替换其中的占位符 + response = response_success.replace("{value}", str(value)) + + return ActionResponse(Action.RESPONSE, str(value), response) + else: + # 查询失败,使用大模型提供的失败响应 + response = response_failure + + return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response) + except Exception as e: + logger.bind(tag=TAG).error(f"查询{device_name}的{prop_name}时出错: {e}") + + # 查询出错时使用大模型提供的失败响应 + response = response_failure + + return ActionResponse(Action.ERROR, str(e), response) + + return wrap_async_function(iot_query_function) + + +async def update_state_by_method(conn, device_name, method_name, params): + """根据方法和参数自动更新设备状态""" + try: + # 规则1: 方法名为TurnOn,设置power为True + if method_name == "TurnOn": + await set_iot_status(conn, device_name, "power", True) + + # 规则2: 方法名为TurnOff,设置power为False + elif method_name == "TurnOff": + await set_iot_status(conn, device_name, "power", False) + + # 规则3: Set开头的方法,尝试更新对应参数 + elif method_name.startswith("Set"): + # 从参数中找到可能的状态值 + for param_name, param_value in params.items(): + # 尝试更新对应名称的属性 + await set_iot_status(conn, device_name, param_name, param_value) + + # 其他方法,尝试直接从参数更新状态 + else: + for param_name, param_value in params.items(): + # 检查设备是否有此属性 + status = await get_iot_status(conn, device_name, param_name) + if status is not None: # 属性存在 + await set_iot_status(conn, device_name, param_name, param_value) + except Exception as e: + logger.bind(tag=TAG).warning(f"自动更新状态失败: {e}") + + class IotDescriptor: """ A class to represent an IoT descriptor. - Attributes: - ---------- - name : str - The name of the IoT descriptor. - description : str - A brief description of the IoT descriptor. - properties : dict - A dictionary containing properties of the IoT descriptor. - methods : dict - A dictionary containing methods of the IoT descriptor. - ------- """ def __init__(self, name, description, properties, methods): @@ -29,17 +156,7 @@ class IotDescriptor: # 根据描述创建属性 for key, value in properties.items(): - # "volume":{"description":"当前音量 值","type":"number"} - """ - 等价于 - { - 'name': 名字, - 'description': 描述, - 'value': 0 - } - """ - # setattr(self, key, {}) # 创建一个空字典, 名字是属性名 - property_item = globals()[key] = {} # 创建一个空字典, 名字是属性名 + property_item = globals()[key] = {} property_item['name'] = key property_item["description"] = value["description"] if value["type"] == "number": @@ -52,23 +169,10 @@ class IotDescriptor: # 根据描述创建方法 for key, value in methods.items(): - # "SetVolume": {"description":"设置音量","parameters":{"volume":{"description":"0到100之间的整数","type":"number"}}} - """ - 等价于 - SetVolume = { - `description`: 描述, - `volume`: { - `description`: 描述, - `value`: 0 - } - } - """ - # setattr(self, key, {}) # 创建一个空字典, 名字是方法名 - method = globals()[key] = {} # 创建一个空字典, 名字是方法名 + method = globals()[key] = {} method["description"] = value["description"] method['name'] = key for k, v in value["parameters"].items(): - # 不同的参数解析 method[k] = {} method[k]["description"] = v["description"] if v["type"] == "number": @@ -77,58 +181,136 @@ class IotDescriptor: method[k]["value"] = False else: method[k]["value"] = "" - self.methods.append(method) -async def handleIotDescriptors(conn, descriptors): - """ - 处理物联网描述 - 示例: [{ - "name":"Speaker", - "description":"当前 AI 机器人的扬声器", - "properties":{ - "volume":{"description":"当前音量 值","type":"number"} 可以有boolean, number, string三种类型 - }, - "methods":{ - "SetVolume":{ - "description":"设置音量","parameters":{"volume":{"description":"0到100之间的整数","type":"number"}} +def register_device_type(descriptor): + """注册设备类型及其功能""" + device_name = descriptor["name"] + type_id = device_type_registry.generate_device_type_id(descriptor) + + # 如果该类型已注册,直接返回类型ID + if type_id in device_type_registry.type_functions: + return type_id + + functions = {} + + # 为每个属性创建查询函数 + for prop_name, prop_info in descriptor["properties"].items(): + func_name = f"get_{device_name.lower()}_{prop_name.lower()}" + func_desc = { + "type": "function", + "function": { + "name": func_name, + "description": f"查询{descriptor['description']}的{prop_info['description']}", + "parameters": { + "type": "object", + "properties": { + "response_success": { + "type": "string", + "description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值" + }, + "response_failure": { + "type": "string", + "description": "查询失败时的友好回复,例如:'无法获取{device_name}的{prop_info['description']}'" + } + }, + "required": ["response_success", "response_failure"] + } } } - }] - descriptors: 描述列表 - """ + query_func = create_iot_query_function(device_name, prop_name, prop_info) + decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(query_func) + functions[func_name] = decorated_func + + # 为每个方法创建控制函数 + for method_name, method_info in descriptor["methods"].items(): + func_name = f"{device_name.lower()}_{method_name.lower()}" + + # 创建参数字典,添加原有参数 + parameters = { + param_name: { + "type": param_info["type"], + "description": param_info["description"] + } + for param_name, param_info in method_info["parameters"].items() + } + + # 添加响应参数 + parameters.update({ + "response_success": { + "type": "string", + "description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称" + }, + "response_failure": { + "type": "string", + "description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称" + } + }) + + # 构建必须参数列表(原有参数 + 响应参数) + required_params = list(method_info["parameters"].keys()) + required_params.extend(["response_success", "response_failure"]) + + func_desc = { + "type": "function", + "function": { + "name": func_name, + "description": f"{descriptor['description']} - {method_info['description']}", + "parameters": { + "type": "object", + "properties": parameters, + "required": required_params + } + } + } + control_func = create_iot_function(device_name, method_name, method_info) + decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(control_func) + functions[func_name] = decorated_func + + device_type_registry.register_device_type(type_id, functions) + return type_id + + +# 用于接受前端设备推送的搜索iot描述 +async def handleIotDescriptors(conn, descriptors): + """处理物联网描述""" + functions_changed = False + for descriptor in descriptors: + # 创建IOT设备描述符 iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"], descriptor["methods"]) conn.iot_descriptors[descriptor["name"]] = iot_descriptor - # 暂时从配置文件中设置音量,后期通过意图识别控制音量 - default_iot_volume = 100 - if "iot" in conn.config: - default_iot_volume = conn.config["iot"]["Speaker"]["volume"] - logger.bind(tag=TAG).info(f"服务端设置音量为{default_iot_volume}") - await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": default_iot_volume}) + if conn.use_function_call_mode: + # 注册或获取设备类型 + type_id = register_device_type(descriptor) + device_functions = device_type_registry.get_device_functions(type_id) + + # 在连接级注册设备函数 + if hasattr(conn, 'func_handler'): + for func_name in device_functions: + conn.func_handler.function_registry.register_function(func_name) + logger.bind(tag=TAG).info(f"注册IOT函数到function handler: {func_name}") + functions_changed = True + + # 如果注册了新函数,更新function描述列表 + if functions_changed and hasattr(conn, 'func_handler'): + conn.func_handler.upload_functions_desc() + func_names = conn.func_handler.current_support_functions() + logger.bind(tag=TAG).info(f"设备类型: {type_id}") + logger.bind(tag=TAG).info(f"更新function描述列表完成,当前支持的函数: {func_names}") + + async def handleIotStatus(conn, states): - """ - 处理物联网状态 - 示例: [{ - "name":"Speaker", - "state":{ - "volume":100 - } - }] - states: 状态列表 - """ + """处理物联网状态""" for state in states: for key, value in conn.iot_descriptors.items(): if key == state["name"]: for property_item in value.properties: - # properties为字典列表, 记录各种属性 for k, v in state["state"].items(): - # state为字典, 记录各种属性的值, 是需要记录的信息 if property_item["name"] == k: - # 检查一下属性是不是相同的 if type(v) != type(property_item["value"]): logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配") break @@ -138,41 +320,35 @@ async def handleIotStatus(conn, states): break break + async def get_iot_status(conn, name, property_name): - """ - 获取物联网状态 - name: 设备名称 "Speaker" - property_name: 属性名称 "volume" - 返回值: 属性值, 实际的属性有int, bool和str三种类型 - """ + """获取物联网状态""" for key, value in conn.iot_descriptors.items(): if key == name: for property_item in value.properties: if property_item["name"] == property_name: return property_item["value"] + logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") return None -async def send_iot_conn(conn, name, method_name, parameters): - """ - 发送物联网指令 - name: 设备名称 "Speaker" - method: 方法 "SetVolume" - parameters: 参数, 是一个字典 {"volume": 100} - 发送示例: - { - "type": "iot", - "commands": [ - { - "name" : "Speaker", - "method": "SetVolume", - "parameters": { - "volume": 100 - } - } - ] - } - """ +async def set_iot_status(conn, name, property_name, value): + """设置物联网状态""" + for key, iot_descriptor in conn.iot_descriptors.items(): + if key == name: + for property_item in iot_descriptor.properties: + if property_item["name"] == property_name: + if type(value) != type(property_item["value"]): + logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配") + return + property_item["value"] = value + logger.bind(tag=TAG).info(f"物联网状态更新: {name} , {property_name} = {value}") + return + logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") + + +async def send_iot_conn(conn, name, method_name, parameters): + """发送物联网指令""" for key, value in conn.iot_descriptors.items(): if key == name: # 找到了设备 diff --git a/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py b/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py index befb14bc..ebfb12f1 100644 --- a/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py +++ b/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py @@ -22,7 +22,7 @@ class TTSProvider(TTSProviderBase): self.cut_punc = config.get("cut_punc","") self.speed = config.get("speed", 1.0) self.inp_refs = config.get("inp_refs",[]) - self.sample_steps = config.get("inp_refs",32) + self.sample_steps = config.get("sample_steps",32) self.if_sr = config.get("if_sr",False) diff --git a/main/xiaozhi-server/plugins_func/functions/play_music.py b/main/xiaozhi-server/plugins_func/functions/play_music.py index b9220ea5..75f3834d 100644 --- a/main/xiaozhi-server/plugins_func/functions/play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/play_music.py @@ -112,7 +112,6 @@ def get_music_files(music_dir, music_ext): def initialize_music_handler(conn): global MUSIC_CACHE if MUSIC_CACHE == {}: - logger.bind(tag=TAG).info(f"实例化音乐:") if "music" in conn.config: MUSIC_CACHE["music_config"] = conn.config["music"] MUSIC_CACHE["music_dir"] = os.path.abspath( diff --git a/main/xiaozhi-server/plugins_func/functions/raise_and_lower_the_volume.py b/main/xiaozhi-server/plugins_func/functions/raise_and_lower_the_volume.py new file mode 100644 index 00000000..2a5aa965 --- /dev/null +++ b/main/xiaozhi-server/plugins_func/functions/raise_and_lower_the_volume.py @@ -0,0 +1,62 @@ +from config.logger import setup_logging +from plugins_func.register import register_function, ToolType, ActionResponse, Action +from core.handle.iotHandle import get_iot_status, send_iot_conn +import asyncio + +TAG = __name__ +logger = setup_logging() + +raise_and_lower_the_volume_function_desc = { + "type": "function", + "function": { + "name": "raise_and_lower_the_volume", + "description": "用户觉得声音过高或过低,或者用户想提高或降低音量。比如用户说太大声了,参数为:lower,比如用户说提高音量,参数为:raise", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "动作名称,要么是raise,要么是lower" + } + }, + "required": ["action"] + } + } +} + + +@register_function('raise_and_lower_the_volume', raise_and_lower_the_volume_function_desc, ToolType.IOT_CTL) +def raise_and_lower_the_volume(conn, action: str): + """ + 获取当前设备音量 + """ + + future = asyncio.run_coroutine_threadsafe( + _raise_and_lower_the_volume(conn, action), + conn.loop + ) + + try: + new_volume = future.result() # 同步等待异步操作完成 + logger.bind(tag=TAG).info(f"音量操作完成: {new_volume}") + response = f"音量已调整到{new_volume}" + except Exception as e: + logger.bind(tag=TAG).error(f"音量操作失败: {e}") + response = f"音量调整失败: {e}" + + return ActionResponse(action=Action.RESPONSE, result="指令已接收", response=response) + + +async def _raise_and_lower_the_volume(conn, action): + volume = await get_iot_status(conn, "Speaker", "volume") + if action == 'raise': + volume += 10 + elif action == 'lower': + volume -= 10 + # 限制音量范围在0到100之间 + if volume < 0: + volume = 0 + elif volume > 100: + volume = 100 + await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": volume}) + return volume diff --git a/main/xiaozhi-server/plugins_func/register.py b/main/xiaozhi-server/plugins_func/register.py index 4c6db461..ccb03b44 100644 --- a/main/xiaozhi-server/plugins_func/register.py +++ b/main/xiaozhi-server/plugins_func/register.py @@ -11,6 +11,7 @@ class ToolType(Enum): WAIT = (2, "调用工具,等待函数返回") CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责") SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数") + IOT_CTL = (5, "IOT设备控制,需要传递conn参数") def __init__(self, code, message): self.code = code @@ -18,6 +19,7 @@ class ToolType(Enum): class Action(Enum): + ERROR = (-1, "错误") NOTFOUND = (0, "没有找到函数") NONE = (1, "啥也不干") RESPONSE = (2, "直接回复") @@ -40,8 +42,31 @@ class FunctionItem: self.func = func self.type = type +class DeviceTypeRegistry: + """设备类型注册表,用于管理IOT设备类型及其函数""" + def __init__(self): + self.type_functions = {} # type_signature -> {func_name: FunctionItem} + + def generate_device_type_id(self, descriptor): + """通过设备能力描述生成类型ID""" + properties = sorted(descriptor["properties"].keys()) + methods = sorted(descriptor["methods"].keys()) + # 使用属性和方法的组合作为设备类型的唯一标识 + type_signature = f"{descriptor['name']}:{','.join(properties)}:{','.join(methods)}" + return type_signature + + def get_device_functions(self, type_id): + """获取设备类型对应的所有函数""" + return self.type_functions.get(type_id, {}) + + def register_device_type(self, type_id, functions): + """注册设备类型及其函数""" + if type_id not in self.type_functions: + self.type_functions[type_id] = functions + # 初始化函数注册字典 all_function_registry = {} +device_type_registry = DeviceTypeRegistry() def register_function(name, desc, type=None): """注册函数到函数注册字典的装饰器"""