427 lines
17 KiB
Python
427 lines
17 KiB
Python
from langchain_core.tools import tool
|
||
from miio import AirConditioningCompanionMcn02
|
||
import json
|
||
from pydantic import BaseModel, Field
|
||
import logging
|
||
import asyncio
|
||
import os
|
||
|
||
# 配置日志
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 默认配置 - 如果 MCP 不可用,会回退到这些配置
|
||
DEFAULT_SYSTEM_USER_ID = 1000000001 # admin 用户ID
|
||
DEFAULT_AC_NAME = "空调"
|
||
AC_IP = "192.168.110.129" # 默认IP(回退用)
|
||
AC_TOKEN = "1724bf8d57b355173dfa08ae23367f86" # 默认Token(回退用)
|
||
AC_MODEL = "lumi.acpartner.mcn02"
|
||
|
||
# 设备缓存(避免频繁查询)
|
||
_device_cache = {}
|
||
|
||
|
||
async def get_device_info_from_mcp(system_user_id: int, device_name: str = "空调") -> dict:
|
||
"""
|
||
通过 MCP 服务获取设备信息
|
||
|
||
注意:这需要 MCP 服务运行,如果不可用会返回 None
|
||
"""
|
||
try:
|
||
from mcp import ClientSession, StdioServerParameters
|
||
from mcp.client.stdio import stdio_client
|
||
|
||
# 获取当前文件所在目录,计算 MCP 服务路径
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
project_root = os.path.dirname(os.path.dirname(current_dir))
|
||
mcp_path = os.path.join(project_root, "mcp", "device_query_mcp.py")
|
||
|
||
logger.info(f"正在通过 MCP 查询设备: {device_name}")
|
||
|
||
# 创建 MCP 客户端
|
||
server_params = StdioServerParameters(
|
||
command="python",
|
||
args=[mcp_path],
|
||
)
|
||
|
||
async with stdio_client(server_params) as (read, write):
|
||
async with ClientSession(read, write) as session:
|
||
# 初始化
|
||
await session.initialize()
|
||
|
||
# 调用工具获取设备信息
|
||
result = await session.call_tool(
|
||
"get_device_by_name",
|
||
arguments={
|
||
"system_user_id": system_user_id,
|
||
"device_name": device_name
|
||
}
|
||
)
|
||
|
||
# 解析结果
|
||
result_data = json.loads(result.content[0].text if hasattr(result, 'content') else result)
|
||
|
||
if result_data.get("success") and result_data.get("devices"):
|
||
devices = result_data["devices"]
|
||
if devices:
|
||
# 返回第一个匹配的设备
|
||
device = devices[0]
|
||
return device
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ 通过 MCP 获取设备信息失败: {e}")
|
||
return None
|
||
|
||
|
||
def get_device_config(device_name: str = DEFAULT_AC_NAME, system_user_id: int = DEFAULT_SYSTEM_USER_ID) -> dict:
|
||
"""
|
||
获取设备配置(优先使用 MCP,失败则使用缓存或默认配置)
|
||
|
||
返回格式:
|
||
{
|
||
"ip": "192.168.110.123",
|
||
"token": "1724bf8d57b355173dfa08ae23367f86",
|
||
"model": "lumi.acpartner.mcn02",
|
||
"name": "客厅空调"
|
||
}
|
||
"""
|
||
cache_key = f"{system_user_id}_{device_name}"
|
||
|
||
# 检查缓存
|
||
if cache_key in _device_cache:
|
||
logger.info(f"使用缓存的设备信息: {device_name}")
|
||
return _device_cache[cache_key]
|
||
|
||
# 尝试通过 MCP 获取
|
||
try:
|
||
# 在线程池中需要创建新的事件循环
|
||
try:
|
||
device_info = asyncio.run(
|
||
get_device_info_from_mcp(system_user_id, device_name)
|
||
)
|
||
except RuntimeError:
|
||
# 如果已经有事件循环在运行,创建新的
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
device_info = loop.run_until_complete(
|
||
get_device_info_from_mcp(system_user_id, device_name)
|
||
)
|
||
finally:
|
||
loop.close()
|
||
|
||
if device_info:
|
||
config = {
|
||
"ip": device_info.get("localip", ""),
|
||
"token": device_info.get("token", ""),
|
||
"model": device_info.get("model", ""),
|
||
"name": device_info.get("name", device_name),
|
||
"did": device_info.get("did", ""),
|
||
"isOnline": device_info.get("isOnline", False),
|
||
}
|
||
|
||
# 缓存设备信息(5分钟有效)
|
||
_device_cache[cache_key] = config
|
||
return config
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取设备配置失败: {e}")
|
||
|
||
# 如果 MCP 不可用,返回默认配置(向后兼容)
|
||
return {
|
||
"ip": AC_IP,
|
||
"token": AC_TOKEN,
|
||
"model": AC_MODEL,
|
||
"name": "空调",
|
||
}
|
||
|
||
|
||
def get_device_connection(device_name: str = DEFAULT_AC_NAME):
|
||
"""获取设备连接"""
|
||
config = get_device_config(device_name)
|
||
|
||
if not config.get("ip") or not config.get("token"):
|
||
raise ValueError(f"设备 {device_name} 配置不完整,缺少 IP 或 Token")
|
||
|
||
return AirConditioningCompanionMcn02(
|
||
ip=config["ip"],
|
||
token=config["token"]
|
||
)
|
||
|
||
|
||
@tool("get_ac_status", description="获取空调当前状态")
|
||
def get_ac_status(device_name: str = DEFAULT_AC_NAME):
|
||
"""
|
||
获取设备状态并以 JSON 格式返回
|
||
|
||
参数:
|
||
device_name: 设备名称,默认为"空调",可以指定具体的设备名如"客厅空调"
|
||
"""
|
||
try:
|
||
config = get_device_config(device_name)
|
||
device = get_device_connection(device_name)
|
||
|
||
props = device.send("get_prop", ["power", "mode", "tar_temp", "fan_level", "ver_swing", "load_power"])
|
||
status = {
|
||
"device_name": config.get("name", device_name),
|
||
"power": props[0],
|
||
"mode": props[1],
|
||
"target_temperature": props[2],
|
||
"fan_level": props[3],
|
||
"vertical_swing": props[4],
|
||
"load_power": props[5],
|
||
"online": True,
|
||
"model": config.get("model", "unknown"),
|
||
"ip": config.get("ip", ""),
|
||
}
|
||
return json.dumps(status, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"获取空调状态失败: {e}")
|
||
config = get_device_config(device_name)
|
||
error_status = {
|
||
"error": f"获取设备状态失败: {str(e)}",
|
||
"message": f"请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确(当前配置:{config.get('ip', 'unknown')})\n3. 设备Token是否正确",
|
||
"online": False,
|
||
"model": config.get("model", "unknown")
|
||
}
|
||
return json.dumps(error_status, indent=2, ensure_ascii=False)
|
||
|
||
|
||
class PowerArgs(BaseModel):
|
||
power: bool = Field(..., description="空调电源状态,true 开启,false 关闭")
|
||
device_name: str = Field(default=DEFAULT_AC_NAME, description="设备名称,默认为'空调'")
|
||
|
||
|
||
@tool("set_ac_power", args_schema=PowerArgs, description="开启或关闭空调。power=true 开启,power=false 关闭")
|
||
def set_ac_power(power: bool, device_name: str = DEFAULT_AC_NAME):
|
||
"""
|
||
设置空调电源状态
|
||
|
||
参数:
|
||
power: True 开启,False 关闭
|
||
device_name: 设备名称,默认为"空调"
|
||
"""
|
||
try:
|
||
config = get_device_config(device_name)
|
||
device = get_device_connection(device_name)
|
||
|
||
if power:
|
||
device.on()
|
||
return json.dumps({
|
||
"message": f"{config.get('name', device_name)} 已开启",
|
||
"power": True
|
||
}, indent=2, ensure_ascii=False)
|
||
else:
|
||
device.off()
|
||
return json.dumps({
|
||
"message": f"{config.get('name', device_name)} 已关闭",
|
||
"power": False
|
||
}, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"设置空调电源失败: {e}")
|
||
config = get_device_config(device_name)
|
||
error_status = {
|
||
"error": f"设置电源状态失败: {str(e)}",
|
||
"message": f"请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确(当前配置:{config.get('ip', 'unknown')})\n3. 设备Token是否正确",
|
||
"online": False,
|
||
"model": config.get("model", "unknown")
|
||
}
|
||
return json.dumps(error_status, indent=2, ensure_ascii=False)
|
||
|
||
|
||
class TemperatureArgs(BaseModel):
|
||
temperature: int = Field(..., ge=16, le=30, description="目标温度(摄氏度),范围 16-30")
|
||
device_name: str = Field(default=DEFAULT_AC_NAME, description="设备名称,默认为'空调'")
|
||
|
||
|
||
@tool("set_ac_temperature", args_schema=TemperatureArgs, description="设置空调目标温度(16-30℃)")
|
||
def set_ac_temperature(temperature: int, device_name: str = DEFAULT_AC_NAME):
|
||
"""
|
||
设置空调目标温度
|
||
|
||
参数:
|
||
temperature: 目标温度(16-30℃)
|
||
device_name: 设备名称,默认为"空调"
|
||
"""
|
||
try:
|
||
config = get_device_config(device_name)
|
||
device = get_device_connection(device_name)
|
||
|
||
# 对于 mcn02,目标温度字段为 tar_temp,对应的设置命令通常为 set_tar_temp
|
||
result = device.send("set_tar_temp", [temperature])
|
||
logger.info(f"{config.get('name', device_name)} 温度已设置为{temperature}℃")
|
||
return json.dumps({
|
||
"message": f"{config.get('name', device_name)} 温度已设置为{temperature}℃",
|
||
"target_temperature": temperature,
|
||
"result": result
|
||
}, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"设置空调温度失败: {e}")
|
||
config = get_device_config(device_name)
|
||
error_status = {
|
||
"error": f"设置温度失败: {str(e)}",
|
||
"message": f"请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确(当前配置:{config.get('ip', 'unknown')})\n3. 设备Token是否正确",
|
||
"online": False,
|
||
"model": config.get("model", "unknown")
|
||
}
|
||
return json.dumps(error_status, indent=2, ensure_ascii=False)
|
||
|
||
|
||
@tool("list_devices", description="查询和列出用户的空调设备信息。当用户询问有哪些空调设备时调用此工具。必须传入 system_user_id 参数。")
|
||
def list_devices(system_user_id: int):
|
||
"""
|
||
查询和列出用户的空调设备信息(只返回空调相关设备)
|
||
|
||
当用户询问以下问题时,必须调用此工具:
|
||
- "空调设备信息"
|
||
|
||
参数:
|
||
system_user_id: 系统用户ID(必传),当前为 1000000001(admin用户)
|
||
|
||
返回:
|
||
空调设备的详细信息,包括设备名称、型号、IP地址、Token、在线状态等
|
||
|
||
注意:此工具会自动从数据库读取用户的米家账户凭证,无需用户提供账号密码
|
||
"""
|
||
try:
|
||
logger.info(f"准备获取用户 {system_user_id} 的设备列表")
|
||
|
||
# 1. 预检查:MCP 服务文件是否存在
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
project_root = os.path.dirname(os.path.dirname(current_dir))
|
||
mcp_path = os.path.join(project_root, "mcp", "device_query_mcp.py")
|
||
|
||
if not os.path.exists(mcp_path):
|
||
logger.error(f"开发错误:MCP 服务文件不存在: {mcp_path}")
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": "请先检查设备查询服务是否启动。"
|
||
}, ensure_ascii=False, indent=2)
|
||
|
||
# 2. 预检查:MCP 依赖是否已安装
|
||
try:
|
||
from mcp import ClientSession, StdioServerParameters
|
||
from mcp.client.stdio import stdio_client
|
||
except ImportError as e:
|
||
logger.error(f"开发错误:MCP 模块未安装: {e}")
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": "请先检查设备查询服务是否启动。"
|
||
}, ensure_ascii=False, indent=2)
|
||
|
||
# 3. 调用 MCP 服务
|
||
logger.info(f"✅ 预检查通过,正在通过 MCP 获取设备列表...")
|
||
|
||
async def get_devices():
|
||
try:
|
||
server_params = StdioServerParameters(
|
||
command="python",
|
||
args=[mcp_path],
|
||
)
|
||
|
||
async with stdio_client(server_params) as (read, write):
|
||
async with ClientSession(read, write) as session:
|
||
await session.initialize()
|
||
|
||
result = await session.call_tool(
|
||
"get_user_devices",
|
||
arguments={"system_user_id": system_user_id}
|
||
)
|
||
|
||
return result.content[0].text if hasattr(result, 'content') else str(result)
|
||
except Exception as e:
|
||
logger.error(f"MCP 调用失败: {e}")
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": "请先检查设备查询服务是否启动。"
|
||
}, ensure_ascii=False, indent=2)
|
||
|
||
# 在线程池中需要创建新的事件循环
|
||
try:
|
||
devices_json = asyncio.run(get_devices())
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
devices_json = loop.run_until_complete(get_devices())
|
||
finally:
|
||
loop.close()
|
||
|
||
# 解析结果
|
||
devices_data = json.loads(devices_json)
|
||
|
||
if devices_data.get("success"):
|
||
all_devices = devices_data.get("devices", [])
|
||
|
||
# 过滤出空调相关的设备
|
||
# 空调设备通常包含 "acpartner"、"aircondition" 等关键词
|
||
ac_devices = []
|
||
for device in all_devices:
|
||
model = device.get("model", "").lower()
|
||
name = device.get("name", "").lower()
|
||
if "acpartner" in model or "aircondition" in model or "空调" in name or "ac" in name:
|
||
ac_devices.append(device)
|
||
|
||
if len(ac_devices) == 0:
|
||
return json.dumps({
|
||
"success": True,
|
||
"message": "未找到空调设备",
|
||
"total_devices": 0,
|
||
"devices": []
|
||
}, indent=2, ensure_ascii=False)
|
||
|
||
# 构建友好的输出(只包含空调设备)
|
||
device_list = []
|
||
for i, device in enumerate(ac_devices, 1):
|
||
device_info = {
|
||
"序号": i,
|
||
"设备名称": device.get("name", "未命名"),
|
||
"型号": device.get("model", "未知"),
|
||
"在线状态": "在线" if device.get("isOnline") else "离线",
|
||
"IP地址": device.get("localip", "N/A"),
|
||
"Token": device.get("token", "N/A"),
|
||
"所属家庭": device.get("home_name", "N/A"),
|
||
}
|
||
device_list.append(device_info)
|
||
|
||
return json.dumps({
|
||
"success": True,
|
||
"message": f"找到 {len(ac_devices)} 个空调设备",
|
||
"total_devices": len(ac_devices),
|
||
"devices": device_list
|
||
}, indent=2, ensure_ascii=False)
|
||
else:
|
||
# 判断是否是凭证问题
|
||
error_msg = devices_data.get("message", "")
|
||
if "未找到小米账号绑定信息" in error_msg or "未找到" in error_msg:
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": "未查询到绑定米家账户的Token,请先绑定米家账户。\n可以通过后端API进行绑定:POST /api/v1/xiaomi/login/start"
|
||
}, indent=2, ensure_ascii=False)
|
||
else:
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": error_msg
|
||
}, indent=2, ensure_ascii=False)
|
||
|
||
except Exception as e:
|
||
error_str = str(e)
|
||
logger.error(f"列出设备失败: {e}")
|
||
|
||
# 判断错误类型,给出友好提示
|
||
if "Connection refused" in error_str or "timeout" in error_str.lower():
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": "请先开启设备查询MCP服务,无法连接到MCP服务。"
|
||
}, indent=2, ensure_ascii=False)
|
||
elif "No module named" in error_str:
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": f"请先安装MCP所需的依赖模块:{error_str}"
|
||
}, indent=2, ensure_ascii=False)
|
||
else:
|
||
return json.dumps({
|
||
"success": False,
|
||
"message": f"获取设备列表失败:{error_str}"
|
||
}, indent=2, ensure_ascii=False) |