server.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. from typing import Any
  5. import uvicorn
  6. from fastmcp import FastMCP
  7. from .auth import load_projects_config
  8. from .collector_api import (
  9. connect_device as api_connect_device,
  10. create_modbus_device as api_create_modbus_device,
  11. create_modbus_point as api_create_modbus_point,
  12. disconnect_device as api_disconnect_device,
  13. edit_modbus_device as api_edit_modbus_device,
  14. edit_modbus_point as api_edit_modbus_point,
  15. list_device_points as api_list_device_points,
  16. list_devices as api_list_devices,
  17. )
  18. from .db import check_database_connection
  19. from .gateway_api import modbus_point_collect_test as api_modbus_point_collect_test
  20. SERVER_INSTRUCTIONS = (
  21. "Data collector tools. Use project.list first to choose a project_key. "
  22. "base_url is used for login and gateway test APIs. data_collector_base_url "
  23. "is required for collector management APIs and never falls back to base_url. "
  24. "For Modbus, gateway word_byte_order values ABCD/BADC/CDAB/DCBA must be "
  25. "mapped to collector byte_order/word_order before creating a device."
  26. )
  27. mcp = FastMCP("data-collector-mcp", instructions=SERVER_INSTRUCTIONS)
  28. logger = logging.getLogger(__name__)
  29. @mcp.tool(
  30. name="project.list",
  31. title="Project List",
  32. description="List enabled 汇采 projects available to this MCP service. Call this first to choose project_key.",
  33. tags={"project", "list"},
  34. )
  35. def project_list() -> dict[str, Any]:
  36. projects = load_projects_config()
  37. result = [
  38. {
  39. "project_key": item["project_key"],
  40. "project_name": item["project_name"],
  41. }
  42. for item in projects
  43. if item["enabled"]
  44. ]
  45. result.sort(key=lambda item: item["project_key"])
  46. return {"projects": result, "total": len(result)}
  47. @mcp.tool(
  48. name="modbus.point_collect_test",
  49. description=(
  50. "通过采集网关读取 Modbus TCP 点位并转换为业务值。调用 "
  51. "{base_url}/api/dc-gateway/modbus/read_points。function_code: "
  52. "1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
  53. "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
  54. "word_byte_order 可选 ABCD、BADC、CDAB、DCBA。若读取成功后要创建汇采设备,"
  55. "映射为 byte_order/word_order: ABCD=>1/1, BADC=>2/1, CDAB=>1/2, DCBA=>2/2。"
  56. "响应透传上游 JSON,code=0 表示业务成功。"
  57. ),
  58. )
  59. def modbus_point_collect_test(
  60. project_key: str,
  61. ip: str,
  62. port: int,
  63. slave_id: int,
  64. points: list[dict[str, Any]],
  65. device_type: str = "ModbusTCP",
  66. word_byte_order: str = "ABCD",
  67. address_base: int = 0,
  68. ) -> dict[str, Any]:
  69. return api_modbus_point_collect_test(
  70. project_key,
  71. ip=ip,
  72. port=port,
  73. slave_id=slave_id,
  74. points=points,
  75. device_type=device_type,
  76. word_byte_order=word_byte_order,
  77. address_base=address_base,
  78. )
  79. @mcp.tool(
  80. name="collector.modbus_device_create",
  81. description=(
  82. "汇采-创建 Modbus 设备。调用 {data_collector_base_url}/api/collector/device,"
  83. "需要登录后使用 Authorization。创建设备必须传 payload.device_type 协议类型、"
  84. "payload.ip IP 地址、payload.port 端口号、payload.name 名称、payload.slave_id、"
  85. "payload.word_order 字顺序、payload.byte_order 字节顺序、payload.address_base。"
  86. "payload.address_base 会转换为汇采接口的 address_offset。"
  87. "payload 会补齐默认值: type=modbus, timeout=3, is_persistent=true, group_id=0, "
  88. "alarm_interval=90, collect_interval=5, retry_times=0。"
  89. "注意 byte_order/word_order 是汇采枚举,不是网关 word_byte_order。"
  90. "byte_order: 1=Big Endian, 2=Small Endian。word_order: 1=Big Endian, 2=Small Endian。"
  91. "device_type: 1=TCP, 2=RTU, 3=UDP, 4=RTU OVER TCP, 5=RTU OVER UDP。"
  92. "采集网关 word_byte_order 映射: ABCD=>1/1, BADC=>2/1, CDAB=>1/2, DCBA=>2/2。"
  93. "响应透传上游 JSON,state=0 表示业务成功。"
  94. ),
  95. )
  96. def collector_modbus_device_create(
  97. project_key: str,
  98. payload: dict[str, Any],
  99. ) -> dict[str, Any]:
  100. return api_create_modbus_device(project_key, payload)
  101. @mcp.tool(
  102. name="collector.modbus_device_edit",
  103. description=(
  104. "汇采-编辑 Modbus 设备。调用 "
  105. "{data_collector_base_url}/api/collector/modbus/device/edit。"
  106. "这是 Modbus 专用旧编辑接口,必须传 ori_id 原设备 id、name、slave_id、"
  107. "word_order、byte_order、device_type 连接类型。TCP/UDP 类设备还必须传 ip 和 port;"
  108. "RTU 设备必须传 serial_port。"
  109. "编辑前设备不能处于已连接状态;若已连接,请先调用 collector.device_disconnect。"
  110. "该接口是全量更新语义,未传字段可能被默认值覆盖。"
  111. "默认参数: ip='', port=0, serial_port='', timeout=3, is_persistent=true, "
  112. "baud_rate=0, data_bit=0, parity=0, stop_bit=0, mode=0, address_offset=0, "
  113. "retry_times=0, device_group_id=0, alarm_interval=90, collect_interval=5。"
  114. "连接类型: 1=TCP, 2=RTU, 3=UDP, 4=RTU OVER TCP, 5=RTU OVER UDP。"
  115. "byte_order: 1=Big Endian, 2=Small Endian;word_order: 1=Big Endian, 2=Small Endian。"
  116. "响应透传上游 JSON,state=0 表示业务成功。"
  117. ),
  118. )
  119. def collector_modbus_device_edit(
  120. project_key: str,
  121. ori_id: int,
  122. name: str,
  123. device_type: int,
  124. slave_id: int,
  125. byte_order: int,
  126. word_order: int,
  127. ip: str = "",
  128. port: int = 0,
  129. serial_port: str = "",
  130. timeout: int = 3,
  131. is_persistent: bool = True,
  132. baud_rate: int = 0,
  133. data_bit: int = 0,
  134. parity: int = 0,
  135. stop_bit: int = 0,
  136. mode: int = 0,
  137. address_offset: int = 0,
  138. retry_times: int = 0,
  139. device_group_id: int = 0,
  140. alarm_interval: int = 90,
  141. collect_interval: int = 5,
  142. ) -> dict[str, Any]:
  143. return api_edit_modbus_device(
  144. project_key,
  145. {
  146. "ori_id": ori_id,
  147. "name": name,
  148. "device_type": device_type,
  149. "ip": ip,
  150. "port": port,
  151. "slave_id": slave_id,
  152. "byte_order": byte_order,
  153. "word_order": word_order,
  154. "serial_port": serial_port,
  155. "timeout": timeout,
  156. "is_persistent": is_persistent,
  157. "baud_rate": baud_rate,
  158. "data_bit": data_bit,
  159. "parity": parity,
  160. "stop_bit": stop_bit,
  161. "mode": mode,
  162. "address_offset": address_offset,
  163. "retry_times": retry_times,
  164. "device_group_id": device_group_id,
  165. "alarm_interval": alarm_interval,
  166. "collect_interval": collect_interval,
  167. },
  168. )
  169. @mcp.tool(
  170. name="collector.modbus_point_create",
  171. description=(
  172. "汇采-创建 Modbus 采集点位。调用 "
  173. "{data_collector_base_url}/api/collector/modbus/point/add_collect_point。"
  174. "创建点位必须传 payload.name 名称、payload.address 寄存器地址、"
  175. "payload.type 数据类型,以及 payload.func_code 或 payload.register_type 寄存器类型。"
  176. "payload 会补齐默认值: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
  177. "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
  178. "func_code: 1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
  179. "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
  180. "register_type 可用 coil、discrete_input、holding_register、input_register。"
  181. "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
  182. "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
  183. "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
  184. "响应透传上游 JSON,state=0 表示业务成功。"
  185. ),
  186. )
  187. def collector_modbus_point_create(
  188. project_key: str,
  189. payload: dict[str, Any],
  190. ) -> dict[str, Any]:
  191. return api_create_modbus_point(project_key, payload)
  192. @mcp.tool(
  193. name="collector.modbus_point_edit",
  194. description=(
  195. "汇采-编辑 Modbus 采集点位。调用 "
  196. "{data_collector_base_url}/api/collector/modbus/point/edit_collect_point。"
  197. "必须传 ori_id 原点位 id、name 名称、address 寄存器地址、data_type 数据类型,"
  198. "以及 func_code 或 register_type 寄存器类型。"
  199. "ori_id 对应 collector.device_points 返回的 data.point[].id。"
  200. "编辑点位不会迁移所属设备。"
  201. "该接口是全量更新语义,未传字段可能被默认值覆盖。"
  202. "默认参数: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
  203. "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
  204. "func_code: 1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
  205. "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
  206. "register_type 可用 coil、discrete_input、holding_register、input_register。"
  207. "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
  208. "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
  209. "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
  210. "响应透传上游 JSON,state=0 表示业务成功。"
  211. ),
  212. )
  213. def collector_modbus_point_edit(
  214. project_key: str,
  215. ori_id: int,
  216. name: str,
  217. address: int,
  218. data_type: str,
  219. func_code: int = 0,
  220. register_type: str = "",
  221. point_id: str = "",
  222. scale_ratio: float = 1,
  223. value_offset: float = 0,
  224. group_id: int = 0,
  225. invalid_values: str = "",
  226. valid_range_start: float | None = None,
  227. valid_range_end: float | None = None,
  228. bit: int = 0,
  229. describe: str = "",
  230. ) -> dict[str, Any]:
  231. payload: dict[str, Any] = {
  232. "ori_id": ori_id,
  233. "name": name,
  234. "address": address,
  235. "type": data_type,
  236. "point_id": point_id,
  237. "scale_ratio": scale_ratio,
  238. "value_offset": value_offset,
  239. "group_id": group_id,
  240. "invalid_values": invalid_values,
  241. "valid_range_start": valid_range_start,
  242. "valid_range_end": valid_range_end,
  243. "bit": bit,
  244. "describe": describe,
  245. }
  246. if func_code:
  247. payload["func_code"] = func_code
  248. else:
  249. payload["register_type"] = register_type
  250. return api_edit_modbus_point(project_key, payload)
  251. @mcp.tool(
  252. name="collector.device_list",
  253. description=(
  254. "汇采-查询设备列表。调用 {data_collector_base_url}/api/collector/device。"
  255. "用于查看设备树、设备分组、设备连接状态、设备采集状态和点位数量;"
  256. "不返回点位明细、点位采集状态或点位当前值。"
  257. "如果目标是查看某个设备下点位的采集状态和值,请使用 collector.device_points。"
  258. "num_points 默认 false;只有需要统计设备或点位分组下的点位数量时才传 true。"
  259. "响应透传上游 JSON,state=0 表示业务成功。"
  260. ),
  261. )
  262. def collector_device_list(project_key: str, num_points: bool = False) -> dict[str, Any]:
  263. return api_list_devices(project_key, num_points=num_points)
  264. @mcp.tool(
  265. name="collector.device_connect",
  266. description=(
  267. "汇采-连接设备。调用 "
  268. "{data_collector_base_url}/api/collector/common/device/set_connect_status,"
  269. "请求 status=2。用于让指定设备进入已连接状态;连接成功不代表正在采集,"
  270. "采集状态请看响应 data.running_status 或后续查询设备/点位状态。"
  271. "device_id 是设备列表中的设备 id;device_type 默认 modbus,其他设备类型可传 "
  272. "s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
  273. "响应透传上游 JSON,state=0 表示业务成功。常见状态:"
  274. "data.status 1=未连接、2=已连接、3=连接异常;"
  275. "data.running_status 0=未采集、1=采集中、2=采集异常。"
  276. ),
  277. )
  278. def collector_device_connect(
  279. project_key: str,
  280. device_id: int,
  281. device_type: str = "modbus",
  282. ) -> dict[str, Any]:
  283. return api_connect_device(project_key, device_id=device_id, device_type=device_type)
  284. @mcp.tool(
  285. name="collector.device_disconnect",
  286. description=(
  287. "汇采-断开设备。调用 "
  288. "{data_collector_base_url}/api/collector/common/device/set_connect_status,"
  289. "请求 status=1。用于停止指定设备连接/采集相关状态,使设备回到未连接或空闲状态。"
  290. "device_id 是设备列表中的设备 id;device_type 默认 modbus,其他设备类型可传 "
  291. "s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
  292. "响应透传上游 JSON,state=0 表示业务成功。常见状态:"
  293. "data.status 1=未连接、2=已连接、3=连接异常;"
  294. "data.running_status 0=未采集、1=采集中、2=采集异常。"
  295. ),
  296. )
  297. def collector_device_disconnect(
  298. project_key: str,
  299. device_id: int,
  300. device_type: str = "modbus",
  301. ) -> dict[str, Any]:
  302. return api_disconnect_device(project_key, device_id=device_id, device_type=device_type)
  303. @mcp.tool(
  304. name="collector.device_points",
  305. description=(
  306. "汇采-查询设备点位列表。调用 "
  307. "{data_collector_base_url}/api/collector/common/device/get_collect_point。"
  308. "主要用于查看某个设备下点位的采集状态和值:Modbus 返回 data.point[].status "
  309. "和 data.point[].present_value,status 0=未采集、1=采集正常、2=采集异常;"
  310. "present_value 是当前内存中的点位最新值。group_id 默认 0 表示查询全部点位,"
  311. "传具体点位分组 id 时只返回该分组下点位。device_type 默认 modbus,其他设备类型可传 "
  312. "s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
  313. "响应透传上游 JSON,state=0 表示业务成功。Modbus 点位常见字段包括 id、point_id、"
  314. "name、address、type、device_id、status、function_code、present_value、scale_ratio、"
  315. "value_offset、group_id、bit、update_time。"
  316. ),
  317. )
  318. def collector_device_points(
  319. project_key: str,
  320. device_id: int,
  321. device_type: str = "modbus",
  322. group_id: int = 0,
  323. ) -> dict[str, Any]:
  324. return api_list_device_points(
  325. project_key,
  326. device_id=device_id,
  327. device_type=device_type,
  328. group_id=group_id,
  329. )
  330. def build_mcp_http_app(path: str | None = None):
  331. effective_path = str(path or os.getenv("MCP_PATH", "/mcp")).strip() or "/mcp"
  332. return mcp.http_app(path=effective_path, transport="http", stateless_http=True)
  333. def main() -> None:
  334. logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
  335. try:
  336. check_database_connection()
  337. except Exception:
  338. logger.exception("Database startup check failed")
  339. raise SystemExit(1)
  340. host = os.getenv("MCP_HOST", "0.0.0.0").strip() or "0.0.0.0"
  341. port = int(os.getenv("MCP_PORT", "8501"))
  342. path = os.getenv("MCP_PATH", "/mcp").strip() or "/mcp"
  343. app = build_mcp_http_app(path)
  344. print(f"Using FastMCP app '{mcp.name}' on http://{host}:{port}{path}")
  345. uvicorn.run(
  346. app,
  347. host=host,
  348. port=port,
  349. lifespan="on",
  350. timeout_graceful_shutdown=2,
  351. ws="websockets-sansio",
  352. )