calculator.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. # server.py
  2. from mcp.server.fastmcp import FastMCP
  3. import sys
  4. import logging
  5. import os
  6. from typing import List, Dict, Any, Optional
  7. import requests
  8. from utils.logger_config import logger
  9. try:
  10. import yaml
  11. except ImportError: # 允许在未安装 PyYAML 的环境下给出清晰日志
  12. yaml = None
  13. # Fix UTF-8 encoding for Windows console
  14. if sys.platform == 'win32':
  15. sys.stderr.reconfigure(encoding='utf-8')
  16. sys.stdout.reconfigure(encoding='utf-8')
  17. # Create an MCP server
  18. mcp = FastMCP("展厅交互模块")
  19. # YAML 配置路径
  20. VIDEO_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'video_config_prod.yaml')
  21. CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.yaml')
  22. # 内存状态
  23. _is_running: bool = False
  24. _current_video_id: Optional[int] = None
  25. def _load_config(config_path: str) -> Dict[str, Any]:
  26. if yaml is None:
  27. return {}
  28. if not os.path.exists(config_path):
  29. return {}
  30. try:
  31. with open(config_path, 'r', encoding='utf-8') as f:
  32. return yaml.safe_load(f) or {}
  33. except Exception as e:
  34. logger.error(f"读取配置文件失败 {config_path}: {e}")
  35. return {}
  36. def _load_videos_from_yaml(config_path: str) -> List[Dict[str, Any]]:
  37. if yaml is None:
  38. logger.error("未安装 PyYAML,请在环境中安装 pyyaml 以读取配置文件")
  39. return []
  40. if not os.path.exists(config_path):
  41. logger.error(f"未找到配置文件: {config_path}")
  42. return []
  43. try:
  44. with open(config_path, 'r', encoding='utf-8') as f:
  45. data = yaml.safe_load(f) or {}
  46. videos = data.get('video_infos', [])
  47. if not isinstance(videos, list):
  48. logger.error("配置文件格式错误: 'video_infos' 应为列表")
  49. return []
  50. # 规范化字段
  51. normalized = []
  52. for v in videos:
  53. if not isinstance(v, dict):
  54. continue
  55. if 'id' not in v or 'name' not in v:
  56. continue
  57. normalized.append({
  58. 'id': v.get('id'),
  59. 'name': v.get('name'),
  60. 'formula': v.get('formula'),
  61. 'description': v.get('description'),
  62. 'video_duration': v.get('video_duration'),
  63. 'video_path': v.get('video_path'),
  64. 'code': v.get('code'),
  65. })
  66. return normalized
  67. except Exception as e:
  68. logger.exception(f"读取配置失败: {e}")
  69. return []
  70. def _index_videos(videos: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
  71. return {int(v['id']): v for v in videos if isinstance(v.get('id'), int)}
  72. # 启动时加载
  73. _videos: List[Dict[str, Any]] = _load_videos_from_yaml(VIDEO_CONFIG_PATH)
  74. _videos_by_id: Dict[int, Dict[str, Any]] = _index_videos(_videos)
  75. # Flask API 基础地址(与 README_API.md 一致)
  76. _app_config = _load_config(CONFIG_PATH)
  77. FLASK_BASE = _app_config.get('flask_api_base', os.environ.get('FLASK_API_BASE', 'http://192.168.254.242:5050'))
  78. # 登录配置
  79. ADMIN_USERNAME = 'admin'
  80. ADMIN_PASSWORD = 'HNYZ0821'
  81. _session = requests.Session()
  82. _has_logged_in = False
  83. def _ensure_login():
  84. global _has_logged_in
  85. if _has_logged_in:
  86. return
  87. # 尝试常用登录路径 (先尝试 /auth/login, 再尝试 /login)
  88. for login_path in ['/auth/login', '/login']:
  89. url = f"{FLASK_BASE}{login_path}"
  90. try:
  91. # 只有当响应不是 404 时才认为是有效的登录端点
  92. resp = _session.post(url, data={
  93. 'username': ADMIN_USERNAME,
  94. 'password': ADMIN_PASSWORD
  95. }, timeout=5)
  96. if resp.status_code != 404:
  97. resp.raise_for_status()
  98. logger.info(f"登录成功: {url}")
  99. _has_logged_in = True
  100. return
  101. except Exception as e:
  102. logger.warning(f"尝试登录 {url} 失败: {e}")
  103. logger.error("无法登录 Flask API,后续请求可能会失败")
  104. def _flask_get(path: str) -> Dict[str, Any]:
  105. _ensure_login()
  106. url = f"{FLASK_BASE}{path}"
  107. try:
  108. resp = _session.get(url, timeout=3)
  109. resp.raise_for_status()
  110. return resp.json()
  111. except Exception as e:
  112. logger.error(f"GET {url} 失败: {e}")
  113. return {"success": False, "message": str(e)}
  114. def _flask_post(path: str, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
  115. _ensure_login()
  116. url = f"{FLASK_BASE}{path}"
  117. try:
  118. resp = _session.post(url, json=json_body or {}, timeout=3)
  119. resp.raise_for_status()
  120. return resp.json()
  121. except Exception as e:
  122. logger.error(f"POST {url} 失败: {e}")
  123. return {"success": False, "message": str(e)}
  124. @mcp.tool(name="获取所有可用的视频列表")
  125. def list_videos() -> dict:
  126. """获取所有可用的视频列表(来自 video_config.yaml)。
  127. 使用建议:
  128. - 当用户只说出展品名称且可能存在谐音/近音时,先调用本工具获取候选列表,
  129. 在客户端侧做相似度/拼音/编辑距离匹配,选取"最接近"的名称对应的 video_id,
  130. 再调用 kodi_start(video_id)。
  131. 返回的每个视频包含以下字段,可用于匹配:
  132. - id: 视频ID(整数)
  133. - name: 展品名称(如"氯化铷"、"硫酸钙新材料"等)
  134. - code: 展品编号(如"1号展品"、"2号展品"等)
  135. - formula: 化学式或公式
  136. - description: 描述信息
  137. """
  138. logger.info(f"获取视频列表,共 {_videos.__len__()} 个")
  139. return {"success": True, "videos": _videos, "count": len(_videos)}
  140. @mcp.tool(name="播放到指定的展品视频")
  141. def kodi_start(video_id: int) -> dict:
  142. """启动或切换 Kodi 播放到指定 video_id(转发到 Flask: POST /api/kodi/start)。
  143. 参数:
  144. - video_id: 整数类型的视频ID(从 list_videos() 获取)
  145. 使用场景和匹配策略:
  146. 1. 用户说编号类表达(如"一号展品"、"1号产品"、"展品1"、"产品1"等):
  147. - 提取数字部分(如"一号"→1、"1号"→1、"展品1"→1)
  148. - 直接使用该数字作为 video_id
  149. 2. 用户直接说物质名称(如"播放氯化铷"、"播放硫酸钙"、"氯化铯介绍视频"等):
  150. - 先调用 list_videos() 获取所有视频列表
  151. - 从返回的列表中匹配:
  152. a) 优先匹配 name 字段(精确或模糊匹配)
  153. b) 其次匹配 formula 字段
  154. c) 再次匹配 code 字段中的名称部分
  155. - 匹配时考虑:
  156. * 精确匹配(如"氯化铷"完全匹配)
  157. * 包含匹配(如输入"氯化铷"匹配到 name="氯化铷")
  158. * 拼音/谐音匹配(如"氯化铯"与"氯化铷"的相似度)
  159. * 编辑距离(允许轻微口误)
  160. - 选择"最接近"的条目,使用其 id 字段作为 video_id 调用本接口
  161. 3. 匹配示例:
  162. - "播放1号展品" → video_id=1
  163. - "播放一号产品" → video_id=1
  164. - "播放氯化铷" → 匹配 name="氯化铷" → video_id=1
  165. - "播放氯化铯介绍视频" → 匹配 name="氯化铯" → video_id=6
  166. - "硫酸钙" → 匹配 name="硫酸钙新材料"(包含匹配) → video_id=2
  167. 提示:如果用户给的是展品"名称"而非 id,且存在谐音/口误,
  168. - 先调用 list_videos() 获取所有视频元数据
  169. - 在调用侧进行名称相似度匹配(可用拼音/编辑距离/分词等方法)
  170. - 选择"最接近"的条目并传入其 video_id 调用本接口
  171. """
  172. if not isinstance(video_id, int) or video_id < 0:
  173. return {"success": False, "message": "video_id 必须为大于等于 0 的整数"}
  174. if video_id not in _videos_by_id:
  175. return {"success": False, "message": f"未找到视频ID={video_id}"}
  176. video = _videos_by_id[video_id]
  177. logger.info(f"请求 Flask 启动/切换播放: 视频ID={video_id},名称={video.get('name')}")
  178. api = _flask_post('/api/kodi/start', {"video_id": video_id})
  179. # 同步内存状态
  180. if api.get('success') is True:
  181. global _is_running, _current_video_id
  182. _is_running = True
  183. _current_video_id = video_id
  184. return api
  185. @mcp.tool(name="设置电视播放视频音量全局音量")
  186. def set_global_volume(volume: int) -> dict:
  187. """设置电视播放视频的全局音量 (转发到 Flask: POST /api/kodi/set_volume)。
  188. 参数:
  189. - volume: 音量值,整数 0-100
  190. """
  191. # 参数校验
  192. if not isinstance(volume, int):
  193. try:
  194. volume = int(volume)
  195. except (ValueError, TypeError):
  196. return {"success": False, "message": "音量值必须为整数"}
  197. if volume < 0 or volume > 100:
  198. return {"success": False, "message": "音量值必须在 0-100 之间"}
  199. logger.info(f"请求设置全局音量: {volume}")
  200. return _flask_post('/api/kodi/set_volume', {"volume": volume})
  201. @mcp.tool(name="打开办公楼大门")
  202. def open_door(door_id: int) -> dict:
  203. """打开办公楼大门 (转发到 Flask: POST /api/door/open)。
  204. 参数:
  205. - door_id: 门ID,整数 (必填)
  206. """
  207. if not isinstance(door_id, int):
  208. return {"success": False, "message": "door_id 必须为整数"}
  209. logger.info(f"请求打开大门: door_id={door_id}")
  210. return _flask_post('/api/door/open', {"door_id": door_id})
  211. @mcp.tool(name="设置办公楼大门模式")
  212. def set_door_mode(control_way: int) -> dict:
  213. """设置办公楼大门模式 (转发到 Flask: POST /api/door/control)。
  214. 参数:
  215. - control_way: 模式,整数 (0:正常模式, 1:常开模式, 2:常闭模式)
  216. """
  217. if not isinstance(control_way, int):
  218. return {"success": False, "message": "control_way 必须为整数"}
  219. if control_way not in [0, 1, 2]:
  220. return {"success": False, "message": "control_way 必须为 0, 1 或 2"}
  221. logger.info(f"请求设置大门模式: control_way={control_way}")
  222. return _flask_post('/api/door/control', {"control_way": control_way})
  223. @mcp.tool(name="打开指定电视")
  224. def turn_on_tv(kodi_id: int) -> dict:
  225. """打开指定 ID 的电视 (转发到 Flask: POST /api/mitv/turn_on)。
  226. 参数:
  227. - kodi_id: 整数 (必填) 对应 Kodi 客户端的 ID/索引
  228. 使用说明:
  229. - 展厅共有 6 台电视,顺序从左到右。
  230. - kodi_id 从 0 开始计数:
  231. * "第一台"、"1号电视"、"左边第一台" -> kodi_id=0
  232. * "第二台"、"2号电视" -> kodi_id=1
  233. * ...
  234. * "第六台"、"6号电视"、"最右边那台" -> kodi_id=5
  235. """
  236. if not isinstance(kodi_id, int):
  237. return {"success": False, "message": "kodi_id 必须为整数"}
  238. logger.info(f"请求打开电视: kodi_id={kodi_id}")
  239. return _flask_post('/api/mitv/turn_on', {"kodi_id": kodi_id})
  240. @mcp.tool(name="关闭指定电视")
  241. def turn_off_tv(kodi_id: int) -> dict:
  242. """关闭指定 ID 的电视 (转发到 Flask: POST /api/mitv/turn_off)。
  243. 参数:
  244. - kodi_id: 整数 (必填) 对应 Kodi 客户端的 ID/索引
  245. 使用说明:
  246. - 展厅共有 6 台电视,顺序从左到右。
  247. - kodi_id 从 0 开始计数:
  248. * "第一台"、"1号电视"、"左边第一台" -> kodi_id=0
  249. * "第二台"、"2号电视" -> kodi_id=1
  250. * ...
  251. * "第六台"、"6号电视"、"最右边那台" -> kodi_id=5
  252. """
  253. if not isinstance(kodi_id, int):
  254. return {"success": False, "message": "kodi_id 必须为整数"}
  255. logger.info(f"请求关闭电视: kodi_id={kodi_id}")
  256. return _flask_post('/api/mitv/turn_off', {"kodi_id": kodi_id})
  257. @mcp.tool(name="打开所有电视")
  258. def turn_on_all_tvs() -> dict:
  259. """打开所有电视 (转发到 Flask: POST /api/mitv/turn_on_all)。"""
  260. logger.info("请求打开所有电视")
  261. return _flask_post('/api/mitv/turn_on_all')
  262. @mcp.tool(name="关闭所有电视")
  263. def turn_off_all_tvs() -> dict:
  264. """关闭所有电视 (转发到 Flask: POST /api/mitv/turn_off_all)。"""
  265. logger.info("请求关闭所有电视")
  266. return _flask_post('/api/mitv/turn_off_all')
  267. # 灯光控制相关工具
  268. @mcp.tool(name="控制一楼大门玄关顶灯")
  269. def control_entrance_lights(action: str) -> dict:
  270. """控制一楼大门玄关顶灯 (转发到 Flask: POST /api/ha/entrance_lights/turn_on|off)。
  271. 参数:
  272. - action: "turn_on" (打开) 或 "turn_off" (关闭)
  273. """
  274. if action not in ["turn_on", "turn_off"]:
  275. return {"success": False, "message": "action 必须为 'turn_on' 或 'turn_off'"}
  276. logger.info(f"请求控制玄关顶灯: {action}")
  277. return _flask_post(f'/api/ha/entrance_lights/{action}')
  278. @mcp.tool(name="控制一楼大门玄关射灯")
  279. def control_exhibition_spotlight(action: str) -> dict:
  280. """控制一楼大门玄关射灯 (转发到 Flask: POST /api/ha/exhibition_spotlight/turn_on|off)。
  281. 参数:
  282. - action: "turn_on" (打开) 或 "turn_off" (关闭)
  283. """
  284. if action not in ["turn_on", "turn_off"]:
  285. return {"success": False, "message": "action 必须为 'turn_on' 或 'turn_off'"}
  286. logger.info(f"请求控制玄关射灯: {action}")
  287. return _flask_post(f'/api/ha/exhibition_spotlight/{action}')
  288. @mcp.tool(name="控制一楼展厅顶灯")
  289. def control_exhibition_ceiling_light(action: str) -> dict:
  290. """控制一楼展厅顶灯 (转发到 Flask: POST /api/ha/exhibition_ceiling_light/turn_on|off)。
  291. 参数:
  292. - action: "turn_on" (打开) 或 "turn_off" (关闭)
  293. """
  294. if action not in ["turn_on", "turn_off"]:
  295. return {"success": False, "message": "action 必须为 'turn_on' 或 'turn_off'"}
  296. logger.info(f"请求控制展厅顶灯: {action}")
  297. return _flask_post(f'/api/ha/exhibition_ceiling_light/{action}')
  298. @mcp.tool(name="控制展厅桌面灯座总开关")
  299. def control_exhibition_desktop_switch(action: str) -> dict:
  300. """控制展厅桌面灯座总开关 (转发到 Flask: POST /api/ha/exhibition_desktop_switch/turn_on|off)。
  301. 参数:
  302. - action: "turn_on" (打开) 或 "turn_off" (关闭)
  303. """
  304. if action not in ["turn_on", "turn_off"]:
  305. return {"success": False, "message": "action 必须为 'turn_on' 或 'turn_off'"}
  306. logger.info(f"请求控制桌面灯座总开关: {action}")
  307. return _flask_post(f'/api/ha/exhibition_desktop_switch/{action}')
  308. @mcp.tool(name="控制展厅桌面3D风扇投影")
  309. def control_exhibition_3d_fan(action: str) -> dict:
  310. """控制展厅桌面3D风扇投影 (转发到 Flask: POST /api/ha/exhibition_3d_fan/turn_on|off)。
  311. 参数:
  312. - action: "turn_on" (打开) 或 "turn_off" (关闭)
  313. """
  314. if action not in ["turn_on", "turn_off"]:
  315. return {"success": False, "message": "action 必须为 'turn_on' 或 'turn_off'"}
  316. logger.info(f"请求控制3D风扇投影: {action}")
  317. return _flask_post(f'/api/ha/exhibition_3d_fan/{action}')
  318. @mcp.tool(name="控制展台桌子灯带")
  319. def control_exhibition_stand_light_strip(action: str) -> dict:
  320. """控制展台桌子灯带 (转发到 Flask: POST /api/ha/exhibition_stand_light_strip/turn_on|off)。
  321. 参数:
  322. - action: "turn_on" (打开) 或 "turn_off" (关闭)
  323. """
  324. if action not in ["turn_on", "turn_off"]:
  325. return {"success": False, "message": "action 必须为 'turn_on' 或 'turn_off'"}
  326. logger.info(f"请求控制展台灯带: {action}")
  327. return _flask_post(f'/api/ha/exhibition_stand_light_strip/{action}')
  328. # Start the server
  329. if __name__ == "__main__":
  330. mcp.run(transport="stdio")