init
This commit is contained in:
90
agents/air_conditioner_agent/README.md
Normal file
90
agents/air_conditioner_agent/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Air Conditioner Agent
|
||||
|
||||
Moss AI 空调控制系统,专门负责家庭空调系统的智能控制。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ⚡ 电源控制(开/关)
|
||||
- 🌡️ 温度调节
|
||||
- 🔄 模式切换
|
||||
- ❄️ 制冷模式
|
||||
- 🔥 制热模式
|
||||
- 💨 送风模式
|
||||
- 💧 除湿模式
|
||||
- 🌪️ 风速调节
|
||||
- 📊 状态查询
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
使用 uv(推荐):
|
||||
```bash
|
||||
cd agents/air_conditioner_agent
|
||||
uv sync
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
使用 uv:
|
||||
```bash
|
||||
uv run .
|
||||
```
|
||||
|
||||
或使用 Python 模块方式:
|
||||
```bash
|
||||
python -m .
|
||||
```
|
||||
|
||||
或运行 main.py:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 命令行参数
|
||||
|
||||
```bash
|
||||
# 指定主机和端口
|
||||
uv run . --host 0.0.0.0 --port 12002
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
服务配置通过项目根目录的 `config.yaml` 文件管理:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
air_conditioner:
|
||||
host: "0.0.0.0"
|
||||
port: 12002
|
||||
```
|
||||
|
||||
## 设备配置
|
||||
|
||||
需要在 `config.yaml` 中配置空调的 IP 和 Token:
|
||||
|
||||
```yaml
|
||||
xiaomi_devices:
|
||||
air_conditioner:
|
||||
ip: "192.168.1.101"
|
||||
token: "your_device_token"
|
||||
model: "xiaomi.aircondition.xxx"
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
- "打开空调"
|
||||
- "设置温度为 22 度"
|
||||
- "切换到制冷模式"
|
||||
- "调高风速"
|
||||
- "关闭空调"
|
||||
|
||||
## 依赖
|
||||
|
||||
- a2a: Agent-to-Agent 协议支持
|
||||
- click: 命令行接口
|
||||
- uvicorn: ASGI 服务器
|
||||
- httpx: HTTP 客户端
|
||||
- PyYAML: YAML 配置解析
|
||||
- python-miio: 小米设备控制库
|
||||
|
||||
7
agents/air_conditioner_agent/__init__.py
Normal file
7
agents/air_conditioner_agent/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Air Conditioner Agent - Moss AI 空调控制系统
|
||||
控制家庭空调系统,包括温度、模式和电源设置
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
106
agents/air_conditioner_agent/__main__.py
Normal file
106
agents/air_conditioner_agent/__main__.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
主入口点 - 用于 `uv run .` 或 `python -m air_conditioner_agent` 启动服务
|
||||
空调 Agent
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import click
|
||||
import logging
|
||||
import uvicorn
|
||||
from a2a.types import (
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentSkill,
|
||||
)
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
import httpx
|
||||
from a2a.server.tasks import (
|
||||
BasePushNotificationSender,
|
||||
InMemoryPushNotificationConfigStore,
|
||||
InMemoryTaskStore,
|
||||
)
|
||||
|
||||
# 确保当前目录和父目录在 Python 路径中
|
||||
current_dir = Path(__file__).parent
|
||||
parent_dir = current_dir.parent
|
||||
if str(current_dir) not in sys.path:
|
||||
sys.path.insert(0, str(current_dir))
|
||||
if str(parent_dir) not in sys.path:
|
||||
sys.path.insert(0, str(parent_dir))
|
||||
|
||||
from executor import AirConditionerAgentExecutor
|
||||
from agent import AirConditionerAgent
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--host", "host", default=None, help="服务主机地址(默认从config.yaml读取)")
|
||||
@click.option("--port", "port", default=None, type=int, help="服务端口(默认从config.yaml读取)")
|
||||
def main(host, port):
|
||||
"""Starts the Air Conditioner Agent server."""
|
||||
try:
|
||||
# 从配置文件读取 host 和 port(如果命令行未指定)
|
||||
if host is None or port is None:
|
||||
from config_loader import get_config_loader
|
||||
config_loader = get_config_loader(strict_mode=False)
|
||||
default_host, default_port = config_loader.get_agent_host_port('air_conditioner')
|
||||
host = host or default_host
|
||||
port = port or default_port
|
||||
|
||||
capabilities = AgentCapabilities(
|
||||
push_notifications=False,
|
||||
state_transition_history=False,
|
||||
streaming=False,
|
||||
)
|
||||
skill = AgentSkill(
|
||||
id="control_air_conditioner",
|
||||
name="Air Conditioner Control",
|
||||
description="控制家庭空调系统,包括温度,模式和电源设置",
|
||||
tags=["air conditioning", "climate control", "home automation"],
|
||||
examples=[
|
||||
"Set AC to 22 degrees",
|
||||
"Turn on the air conditioner",
|
||||
"Change AC mode to cooling",
|
||||
],
|
||||
)
|
||||
agent_card = AgentCard(
|
||||
name="Air Conditioner Agent",
|
||||
description="家用空调系统控制的专业助手",
|
||||
url=f"http://{host}:{port}/",
|
||||
version="1.0.0",
|
||||
default_input_modes=AirConditionerAgent.SUPPORTED_CONTENT_TYPES,
|
||||
default_output_modes=AirConditionerAgent.SUPPORTED_CONTENT_TYPES,
|
||||
capabilities=capabilities,
|
||||
skills=[skill],
|
||||
)
|
||||
|
||||
# --8<-- [start:DefaultRequestHandler]
|
||||
httpx_client = httpx.AsyncClient()
|
||||
push_config_store = InMemoryPushNotificationConfigStore()
|
||||
push_sender = BasePushNotificationSender(
|
||||
httpx_client=httpx_client, config_store=push_config_store
|
||||
)
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=AirConditionerAgentExecutor(),
|
||||
task_store=InMemoryTaskStore(),
|
||||
push_config_store=push_config_store,
|
||||
push_sender=push_sender,
|
||||
)
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=agent_card, http_handler=request_handler
|
||||
)
|
||||
|
||||
uvicorn.run(server.build(), host=host, port=port)
|
||||
# --8<-- [end:DefaultRequestHandler]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during server startup: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
151
agents/air_conditioner_agent/agent.py
Normal file
151
agents/air_conditioner_agent/agent.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from typing import Any
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加父目录到路径以导入config_loader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from config_loader import get_config_loader
|
||||
|
||||
from tools import get_ac_status, set_ac_power, set_ac_temperature
|
||||
|
||||
memory = MemorySaver()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AirConditionerAgent:
|
||||
SUPPORTED_CONTENT_TYPES = ['text', 'text/plain']
|
||||
|
||||
# 默认系统提示词(备用)
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
'你是一个专门的家庭智能设备管理助手。'
|
||||
'你的主要功能包括:1)控制空调系统;2)查询和管理用户的米家智能设备。'
|
||||
'\n\n## 设备查询功能'
|
||||
'当用户询问"我有哪些设备"、"列出设备"、"设备列表"等问题时,必须调用 list_devices 工具获取所有米家设备信息。'
|
||||
'**重要**: 调用 list_devices 时必须传入 system_user_id 参数,当前用户为 admin,系统ID为 1000000001。'
|
||||
'该工具会自动从数据库读取用户的米家账户凭证,无需用户输入账号密码。'
|
||||
'如果工具返回"未查询到绑定米家账户的Token",请友好地告知用户需要先绑定米家账户。'
|
||||
'如果工具返回"请先开启设备查询MCP",请告知用户MCP服务未启动。'
|
||||
'\n\n## 空调控制功能'
|
||||
'你可以帮助调节温度、设置模式(制冷、制热、送风等)、打开或关闭空调,以及提供节能建议。'
|
||||
'当用户请求查询设备状态时,一定要调用工具 get_ac_status 获取最新状态,并将结果直接返回给用户;如工具返回 JSON,请原样返回或提取关键字段用中文概述。'
|
||||
'当用户请求"启动/打开/关闭空调"等同义表达时,必须调用 set_ac_power(power: bool) 工具执行,并向用户反馈执行结果。'
|
||||
'当用户请求设置温度(如"调到26度/设置到23℃")时,必须调用 set_ac_temperature(temperature: int) 工具执行;如用户未给出明确温度,先向用户确认目标温度(范围16-30℃)。'
|
||||
'\n\n## 智能温度调节'
|
||||
'当用户以语义描述温感(如"有点热/太热/冷一点/暖一点/舒服点/睡觉用")而未给出具体温度时,按以下规则自动设置人类适宜温度:'
|
||||
'1) 先调用 get_ac_status 获取当前 power、mode、tar_temp;若电源关闭且需要调温,先调用 set_ac_power(true)。'
|
||||
'2) 若 mode 为 制冷/自动 且用户表达"有点热/太热/降温/冷一点",将目标温度在当前基础上降低1-2℃(默认2℃),不低于24℃;若表达"有点冷/太冷/升温/暖一点",则提高1-2℃(默认2℃),不高于30℃,然后调用 set_ac_temperature。'
|
||||
'3) 若 mode 为 制热 且用户表达"有点冷/太冷/升温/暖一点",在当前基础上提高1-2℃(默认2℃),不高于26℃;若表达"有点热/太热/降温/冷一点",则降低1-2℃(默认2℃),不低于16℃,然后调用 set_ac_temperature。'
|
||||
'4) 若用户表达"舒适/舒服点",则:制冷模式设为26℃,制热模式设为22℃;若无法判断模式,则先查询状态后按模式执行。'
|
||||
'5) 若用户表达"睡觉/睡眠",则:制冷模式设为27℃,制热模式设为21℃。'
|
||||
'所有自动推断出的目标温度都必须限制在16-30℃区间内。设置完成后,用中文简要说明采用了哪条规则与最终温度。'
|
||||
'\n\n如果用户询问与智能设备管理或空调控制无关的内容,请礼貌地说明你只能协助处理智能设备相关的问题。'
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
# 从数据库加载配置(严格模式:配置加载失败则退出)
|
||||
try:
|
||||
config_loader = get_config_loader(strict_mode=True)
|
||||
|
||||
# 加载AI模型配置
|
||||
ai_config = config_loader.get_default_ai_model_config()
|
||||
logger.info(f"✅ 成功加载AI模型配置: {ai_config['model']}")
|
||||
self.model = ChatOpenAI(
|
||||
model=ai_config['model'],
|
||||
api_key=ai_config['api_key'],
|
||||
base_url=ai_config['api_base'],
|
||||
temperature=ai_config['temperature'],
|
||||
)
|
||||
|
||||
# 加载系统提示词
|
||||
try:
|
||||
system_prompt = config_loader.get_agent_prompt('air_conditioner')
|
||||
self.SYSTEM_PROMPT = system_prompt
|
||||
except Exception as e:
|
||||
self.SYSTEM_PROMPT = self.DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 配置加载失败: {e}")
|
||||
logger.error("⚠️ 请确保:")
|
||||
logger.error(" 1. StarRocks 数据库已启动")
|
||||
logger.error(" 2. 已执行数据库初始化脚本: data/init_config.sql 和 data/ai_config.sql")
|
||||
logger.error(" 3. config.yaml 中的数据库连接配置正确")
|
||||
raise SystemExit(1) from e
|
||||
|
||||
from tools import list_devices
|
||||
self.tools = [get_ac_status, set_ac_power, set_ac_temperature, list_devices]
|
||||
|
||||
self.graph = create_react_agent(
|
||||
self.model,
|
||||
tools=self.tools,
|
||||
checkpointer=memory,
|
||||
prompt=self.SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
async def invoke(self, query, context_id) -> dict[str, Any]:
|
||||
"""非流式调用,直接返回最终结果"""
|
||||
inputs = {'messages': [('user', query)]}
|
||||
config = {'configurable': {'thread_id': context_id}}
|
||||
|
||||
# 直接调用invoke,不使用stream
|
||||
result = self.graph.invoke(inputs, config)
|
||||
|
||||
return self.get_agent_response(config)
|
||||
|
||||
def _extract_text_from_message(self, msg: AIMessage | ToolMessage | Any) -> str:
|
||||
try:
|
||||
content = getattr(msg, 'content', None)
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and 'text' in part:
|
||||
parts.append(part['text'])
|
||||
if parts:
|
||||
return '\n'.join(parts)
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
|
||||
def get_agent_response(self, config):
|
||||
current_state = self.graph.get_state(config)
|
||||
messages = current_state.values.get('messages') if hasattr(current_state, 'values') else None
|
||||
|
||||
# 优先返回最近一次工具消息内容
|
||||
if isinstance(messages, list) and messages:
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, ToolMessage):
|
||||
tool_text = self._extract_text_from_message(msg)
|
||||
if tool_text:
|
||||
return {
|
||||
'is_task_complete': True,
|
||||
'require_user_input': False,
|
||||
'content': tool_text,
|
||||
}
|
||||
|
||||
# 回退到最后一条 AI 消息
|
||||
final_text = ''
|
||||
if isinstance(messages, list) and messages:
|
||||
last_msg = messages[-1]
|
||||
final_text = self._extract_text_from_message(last_msg)
|
||||
|
||||
if not final_text:
|
||||
return {
|
||||
'is_task_complete': False,
|
||||
'require_user_input': True,
|
||||
'content': '当前无法处理您的请求,请稍后重试。',
|
||||
}
|
||||
|
||||
return {
|
||||
'is_task_complete': True,
|
||||
'require_user_input': False,
|
||||
'content': final_text,
|
||||
}
|
||||
|
||||
96
agents/air_conditioner_agent/executor.py
Normal file
96
agents/air_conditioner_agent/executor.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.tasks import TaskUpdater
|
||||
from a2a.types import (
|
||||
InternalError,
|
||||
InvalidParamsError,
|
||||
Part,
|
||||
TaskState,
|
||||
TextPart,
|
||||
UnsupportedOperationError,
|
||||
)
|
||||
from a2a.utils import (
|
||||
new_agent_text_message,
|
||||
new_task,
|
||||
)
|
||||
from a2a.utils.errors import ServerError
|
||||
|
||||
from agent import AirConditionerAgent
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AirConditionerAgentExecutor(AgentExecutor):
|
||||
"""Air Conditioner AgentExecutor."""
|
||||
|
||||
def __init__(self):
|
||||
self.agent = AirConditionerAgent()
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
context: RequestContext,
|
||||
event_queue: EventQueue,
|
||||
) -> None:
|
||||
error = self._validate_request(context)
|
||||
if error:
|
||||
raise ServerError(error=InvalidParamsError())
|
||||
|
||||
query = context.get_user_input()
|
||||
task = context.current_task
|
||||
if not task:
|
||||
task = new_task(context.message) # type: ignore
|
||||
await event_queue.enqueue_event(task)
|
||||
updater = TaskUpdater(event_queue, task.id, task.context_id)
|
||||
try:
|
||||
# 使用非流式invoke方法
|
||||
result = await self.agent.invoke(query, task.context_id)
|
||||
|
||||
is_task_complete = result.get('is_task_complete', True)
|
||||
require_user_input = result.get('require_user_input', False)
|
||||
content = result.get('content', '处理完成')
|
||||
|
||||
if require_user_input:
|
||||
await updater.update_status(
|
||||
TaskState.input_required,
|
||||
new_agent_text_message(
|
||||
content,
|
||||
task.context_id,
|
||||
task.id,
|
||||
),
|
||||
final=True,
|
||||
)
|
||||
elif is_task_complete:
|
||||
await updater.add_artifact(
|
||||
[Part(root=TextPart(text=content))],
|
||||
name='ac_status_result',
|
||||
)
|
||||
await updater.complete()
|
||||
else:
|
||||
# 如果既不需要输入也未完成,设置为working状态
|
||||
await updater.update_status(
|
||||
TaskState.working,
|
||||
new_agent_text_message(
|
||||
content,
|
||||
task.context_id,
|
||||
task.id,
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'An error occurred while processing the request: {e}')
|
||||
raise ServerError(error=InternalError()) from e
|
||||
|
||||
def _validate_request(self, context: RequestContext) -> bool:
|
||||
# 这里可以添加请求验证逻辑
|
||||
# 返回 True 表示有错误,False 表示验证通过
|
||||
return False
|
||||
|
||||
async def cancel(
|
||||
self, context: RequestContext, event_queue: EventQueue
|
||||
) -> None:
|
||||
raise ServerError(error=UnsupportedOperationError())
|
||||
|
||||
26
agents/air_conditioner_agent/pyproject.toml
Normal file
26
agents/air_conditioner_agent/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "air-conditioner-agent"
|
||||
version = "1.0.0"
|
||||
description = "Moss AI 空调控制 Agent"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"a2a>=0.1.0",
|
||||
"click>=8.0.0",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"httpx>=0.25.0",
|
||||
"PyYAML>=6.0.0",
|
||||
"starlette>=0.27.0",
|
||||
"python-miio>=0.5.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = []
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
427
agents/air_conditioner_agent/tools.py
Normal file
427
agents/air_conditioner_agent/tools.py
Normal file
@@ -0,0 +1,427 @@
|
||||
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)
|
||||
1574
agents/air_conditioner_agent/uv.lock
generated
Normal file
1574
agents/air_conditioner_agent/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user