This commit is contained in:
雷雨
2025-12-15 22:05:56 +08:00
commit 8635b84b2d
230 changed files with 53888 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"""
Air Cleaner Agent - Moss AI 空气净化器控制系统
控制桌面空气净化器zhimi-oa1
"""
__version__ = "1.0.0"

View File

@@ -0,0 +1,108 @@
"""
主入口点 - 用于 `uv run .` 或 `python -m air_cleaner_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 AirPurifierAgentExecutor
from agent import AirPurifierAgent
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 Purifier 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_cleaner')
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_purifier",
name="Air Purifier Control",
description="控制桌面空气净化器zhimi-oa1包括电源、风扇等级、工作模式、LED亮度查询PM2.5、湿度、滤芯寿命等",
tags=["air purifier", "air quality", "PM2.5", "home automation", "smart home"],
examples=[
"打开空气净化器",
"查询当前PM2.5",
"设置为睡眠模式",
"把风扇调到高速",
"关闭LED灯",
],
)
agent_card = AgentCard(
name="Air Purifier Agent",
description="桌面空气净化器zhimi-oa1控制的专业助手",
url=f"http://{host}:{port}/",
version="1.0.0",
default_input_modes=AirPurifierAgent.SUPPORTED_CONTENT_TYPES,
default_output_modes=AirPurifierAgent.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=AirPurifierAgentExecutor(),
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()

View File

@@ -0,0 +1,179 @@
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_purifier_status,
set_purifier_power,
set_purifier_mode,
set_purifier_fan_level,
set_purifier_led,
set_purifier_alarm,
set_purifier_child_lock
)
memory = MemorySaver()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AirPurifierAgent:
SUPPORTED_CONTENT_TYPES = ['text', 'text/plain']
# 默认系统提示词(备用)
DEFAULT_SYSTEM_PROMPT = (
'你是一个专门的桌面空气净化器控制助手型号zhimi-oa1'
'你的唯一目的是帮助用户控制他们的桌面空气净化器。'
'你可以帮助开关净化器、查看空气质量PM2.5、湿度、调节风扇等级1-4档'
'控制LED按键亮度、提示音开关、童锁、查看滤芯寿命等。'
'如果用户询问与空气净化器控制或空气质量无关的内容,'
'请礼貌地说明你无法帮助处理该主题,只能协助处理与空气净化器相关的问题。'
'不要尝试回答无关问题或将工具用于其他目的。'
''
'工具使用指南:'
'1. 查询状态当用户请求查询设备状态、空气质量、PM2.5、湿度、滤芯等信息时,'
' 调用 get_purifier_status 获取最新状态,并用中文友好地展示关键信息。'
' 重点关注电源状态、PM2.5值、湿度、风扇等级、滤芯剩余寿命。'
''
'2. 电源控制:当用户说"打开/开启/启动净化器"时,调用 set_purifier_power(power=True)'
'"关闭/关掉净化器"时,调用 set_purifier_power(power=False)。'
''
'3. 工作模式支持0=自动模式根据PM2.5自动调节、1=睡眠模式低噪音、2=手动模式(手动设置风扇等级)。'
' 使用 set_purifier_mode(mode=0/1/2) 设置。注意要手动设置风扇等级设备必须先切换到手动模式mode=2'
''
'4. 风扇等级支持1-4档当用户说"一档/最小风"时设为1"二档"时设为2'
' "三档"时设为3"四档/最大风/强力"时设为4使用 set_purifier_fan_level(level=1/2/3/4)。'
' **重要**set_purifier_fan_level 工具会自动检查并切换到手动模式,无需手动调用 set_purifier_mode。'
''
'5. LED控制当用户说"开启LED/开灯"时设为True"关闭LED/关灯"时设为False'
' 使用 set_purifier_led(brightness=True/False)。'
''
'6. 提示音控制:当用户说"开启提示音/打开声音"时设为True"关闭提示音/静音"时设为False'
' 使用 set_purifier_alarm(alarm=True/False)。'
''
'7. 童锁控制:当用户说"开启童锁/锁定按键"时设为True"关闭童锁/解锁按键"时设为False'
' 使用 set_purifier_child_lock(child_lock=True/False)。'
''
'8. 智能场景建议:'
' - 空气质量差PM2.5>75建议开启并设为高速档4档或自动模式'
' - 睡眠时段建议设为睡眠模式mode=1或低速档1档+关闭LED+关闭提示音'
' - 滤芯寿命<10%:提醒用户更换滤芯'
' - 空气质量好PM2.5<35可建议降低风扇等级、切换到自动模式或关闭以节能'
''
'始终用友好、简洁的中文回复用户,优先展示用户最关心的信息。'
)
def __init__(self):
# 从数据库加载配置(严格模式:配置加载失败则退出)
try:
config_loader = get_config_loader(strict_mode=True)
# 加载AI模型配置
ai_config = config_loader.get_default_ai_model_config()
self.model = ChatOpenAI(
model=ai_config['model'],
api_key=ai_config['api_key'],
base_url=ai_config['api_base'],
temperature=ai_config['temperature'],
)
# 加载系统提示词
system_prompt = config_loader.get_agent_prompt('air_cleaner')
self.SYSTEM_PROMPT = 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
self.tools = [
get_purifier_status,
set_purifier_power,
set_purifier_mode,
set_purifier_fan_level,
set_purifier_led,
set_purifier_alarm,
set_purifier_child_lock
]
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,
}

View 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 AirPurifierAgent
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AirPurifierAgentExecutor(AgentExecutor):
"""Air Purifier AgentExecutor."""
def __init__(self):
self.agent = AirPurifierAgent()
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='purifier_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())

View File

@@ -0,0 +1,26 @@
[project]
name = "air-cleaner-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 = ["."]

View File

@@ -0,0 +1,251 @@
from langchain_core.tools import tool
from miio import DeviceFactory
from miio.miot_device import MiotDevice
import json
from pydantic import BaseModel, Field
import logging
import threading
# 配置日志
logger = logging.getLogger(__name__)
# 设备配置
PURIFIER_IP = "192.168.110.120"
PURIFIER_TOKEN = "569905df67a11d6b67a575097255c798"
PURIFIER_MODEL = "zhimi.airp.oa1"
# 创建设备实例(使用 DeviceFactory 自动识别设备类型)
device = DeviceFactory.create(PURIFIER_IP, PURIFIER_TOKEN)
# 同时保留 MiotDevice 实例用于属性设置
miot_device = MiotDevice(
ip=PURIFIER_IP,
token=PURIFIER_TOKEN,
model=PURIFIER_MODEL
)
# 添加线程锁,确保同一时间只有一个操作
device_lock = threading.Lock()
@tool("get_purifier_status", description="获取空气净化器当前状态包括电源、PM2.5、湿度、风扇等级、工作模式、滤芯寿命等信息")
def get_purifier_status():
"""获取空气净化器设备状态并以 JSON 格式返回"""
try:
with device_lock: # 使用锁确保串行执行
# 使用 status() 方法一次性获取所有状态
status_obj = device.status()
# 获取状态数据字典
if hasattr(status_obj, 'data'):
status_data = status_obj.data
# 添加额外信息
status_data['online'] = True
status_data['model'] = PURIFIER_MODEL
return json.dumps(status_data, indent=2, ensure_ascii=False, default=str)
else:
# 降级方案:如果没有 data 属性,手动获取关键属性
logger.warning("设备状态对象没有 data 属性,使用降级方案")
power = miot_device.get_property_by(2, 1)
fan_level = miot_device.get_property_by(2, 5) # 正确的 PIID
led = miot_device.get_property_by(2, 6)
status = {
"power": power[0].get('value') if isinstance(power, list) and len(power) > 0 else power,
"fan_level": fan_level[0].get('value') if isinstance(fan_level, list) and len(fan_level) > 0 else fan_level,
"led_brightness": led[0].get('value') if isinstance(led, list) and len(led) > 0 else led,
"online": True,
"model": PURIFIER_MODEL
}
return json.dumps(status, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"获取空气净化器状态失败: {e}")
error_status = {
"error": f"获取设备状态失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}
return json.dumps(error_status, indent=2, ensure_ascii=False)
class PowerArgs(BaseModel):
power: bool = Field(..., description="空气净化器电源状态true 开启false 关闭")
@tool("set_purifier_power", args_schema=PowerArgs, description="开启或关闭空气净化器。power=true 开启power=false 关闭")
def set_purifier_power(power: bool):
"""开启或关闭空气净化器"""
try:
with device_lock: # 使用锁确保串行执行
result = miot_device.set_property_by(2, 1, power)
action = "开启" if power else "关闭"
logger.info(f"空气净化器已{action}")
return json.dumps({
"message": f"空气净化器已{action}",
"power": power,
"result": str(result)
}, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"设置空气净化器电源失败: {e}")
return json.dumps({
"error": f"设置电源状态失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}, indent=2, ensure_ascii=False)
class ModeArgs(BaseModel):
mode: int = Field(..., ge=0, le=2, description="工作模式0=自动模式1=睡眠模式2=手动模式")
@tool("set_purifier_mode", args_schema=ModeArgs, description="设置空气净化器工作模式0=自动1=睡眠2=手动)")
def set_purifier_mode(mode: int):
"""设置空气净化器工作模式"""
try:
with device_lock:
result = miot_device.set_property_by(2, 4, mode) # PIID 4: mode
mode_names = {0: "自动模式", 1: "睡眠模式", 2: "手动模式"}
mode_name = mode_names.get(mode, f"模式{mode}")
logger.info(f"工作模式已设置为{mode_name}")
return json.dumps({
"message": f"工作模式已设置为{mode_name}",
"mode": mode,
"mode_name": mode_name,
"result": str(result)
}, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"设置空气净化器工作模式失败: {e}")
return json.dumps({
"error": f"设置工作模式失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}, indent=2, ensure_ascii=False)
class FanLevelArgs(BaseModel):
level: int = Field(..., ge=1, le=4, description="风扇等级,范围 1-4 (1档、2档、3档、4档)")
@tool("set_purifier_fan_level", args_schema=FanLevelArgs, description="设置空气净化器风扇等级1档、2档、3档、4档")
def set_purifier_fan_level(level: int):
"""设置空气净化器风扇等级
注意要手动设置风扇等级设备必须处于手动模式mode=2
如果设备处于自动模式,会自动先切换到手动模式。
"""
try:
with device_lock:
# 先检查当前模式
try:
current_mode_result = miot_device.get_property_by(2, 4)
current_mode = current_mode_result[0].get('value') if isinstance(current_mode_result, list) else current_mode_result
# 如果不是手动模式mode != 2先切换到手动模式
if current_mode != 2:
logger.info(f"当前为模式{current_mode},需要先切换到手动模式才能设置风扇等级")
mode_result = miot_device.set_property_by(2, 4, 2) # 切换到手动模式
logger.info(f"已自动切换到手动模式: {mode_result}")
except Exception as mode_error:
logger.warning(f"获取/切换模式时出错,继续尝试设置风扇等级: {mode_error}")
# 设置风扇等级
result = miot_device.set_property_by(2, 5, level) # PIID 5: fan_level
logger.info(f"风扇等级已设置为{level}")
return json.dumps({
"message": f"风扇等级已设置为{level}档(已切换到手动模式)",
"fan_level": level,
"mode": "手动模式",
"result": str(result)
}, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"设置空气净化器风扇等级失败: {e}")
return json.dumps({
"error": f"设置风扇等级失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}, indent=2, ensure_ascii=False)
class LEDBrightnessArgs(BaseModel):
brightness: bool = Field(..., description="LED按键亮度开关true 开启false 关闭")
@tool("set_purifier_led", args_schema=LEDBrightnessArgs, description="设置空气净化器LED按键亮度开关")
def set_purifier_led(brightness: bool):
"""设置空气净化器LED按键亮度"""
try:
with device_lock:
result = miot_device.set_property_by(2, 6, brightness) # 正确的 PIID 是 (2, 6)
status = "开启" if brightness else "关闭"
logger.info(f"LED亮度已{status}")
return json.dumps({
"message": f"LED亮度已{status}",
"led_brightness": brightness,
"result": str(result)
}, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"设置空气净化器LED失败: {e}")
return json.dumps({
"error": f"设置LED亮度失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}, indent=2, ensure_ascii=False)
class AlarmArgs(BaseModel):
alarm: bool = Field(..., description="提示音开关true 开启false 关闭")
@tool("set_purifier_alarm", args_schema=AlarmArgs, description="设置空气净化器提示音开关")
def set_purifier_alarm(alarm: bool):
"""设置空气净化器提示音"""
try:
with device_lock:
result = miot_device.set_property_by(2, 7, alarm) # PIID 7: alarm
status = "开启" if alarm else "关闭"
logger.info(f"提示音已{status}")
return json.dumps({
"message": f"提示音已{status}",
"alarm": alarm,
"result": str(result)
}, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"设置空气净化器提示音失败: {e}")
return json.dumps({
"error": f"设置提示音失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}, indent=2, ensure_ascii=False)
class ChildLockArgs(BaseModel):
child_lock: bool = Field(..., description="童锁开关true 开启false 关闭")
@tool("set_purifier_child_lock", args_schema=ChildLockArgs, description="设置空气净化器童锁(物理控制锁)")
def set_purifier_child_lock(child_lock: bool):
"""设置空气净化器童锁"""
try:
with device_lock:
result = miot_device.set_property_by(2, 9, child_lock) # PIID 9: physical-controls-locked
status = "开启" if child_lock else "关闭"
logger.info(f"童锁已{status}")
return json.dumps({
"message": f"童锁已{status}",
"child_lock": child_lock,
"result": str(result)
}, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"设置空气净化器童锁失败: {e}")
return json.dumps({
"error": f"设置童锁失败: {str(e)}",
"message": "请检查:\n1. 设备是否已开启并连接到网络\n2. 设备IP地址是否配置正确当前配置{ip}\n3. 设备Token是否正确".format(ip=PURIFIER_IP),
"online": False,
"model": PURIFIER_MODEL
}, indent=2, ensure_ascii=False)

1574
agents/air_cleaner_agent/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff