s7_server.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. from __future__ import annotations
  2. from typing import Any, NotRequired, TypedDict
  3. from pydantic import BaseModel
  4. from .collector_api import (
  5. create_s7_devices as api_create_s7_devices,
  6. create_s7_points as api_create_s7_points,
  7. edit_s7_device as api_edit_s7_device,
  8. edit_s7_point as api_edit_s7_point,
  9. )
  10. from .gateway_api import (
  11. s7_connect_scan as api_s7_connect_scan,
  12. s7_point_collect_test as api_s7_point_collect_test,
  13. s7_raw_read as api_s7_raw_read,
  14. )
  15. from .mcp_app import mcp
  16. class S7DeviceCreateItem(BaseModel):
  17. name: str
  18. ip: str
  19. rock: int
  20. slot: int
  21. port: int = 102
  22. device_type: int = 1
  23. tsap_conn_type: str | None = None
  24. is_persistent: bool = False
  25. device_group_id: int = 0
  26. group_id: int | None = None
  27. timeout: int = 3
  28. alarm_interval: int = 90
  29. collect_interval: int = 5
  30. class S7PointCreateItem(TypedDict):
  31. device_id: int
  32. name: str
  33. address: str
  34. data_type: NotRequired[str]
  35. type: NotRequired[str]
  36. register_type: NotRequired[int]
  37. register_area: NotRequired[str]
  38. point_id: NotRequired[str]
  39. scale_ratio: NotRequired[float]
  40. value_offset: NotRequired[float]
  41. group_id: NotRequired[int]
  42. invalid_values: NotRequired[str]
  43. valid_range_start: NotRequired[float | None]
  44. valid_range_end: NotRequired[float | None]
  45. describe: NotRequired[str]
  46. @mcp.tool(
  47. name="s7.raw_read",
  48. description=(
  49. "通过采集网关对 Siemens S7 TCP 设备执行原始字节读取。调用 "
  50. "{base_url}/api/dc-gateway/s7/read。请求字段使用网关/汇采一致命名:"
  51. "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
  52. "device_type 为设备类型,默认 S7-1200,可传 S7-1200、S7-Smart200。"
  53. "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
  54. "read 必须包含 area、start、size;"
  55. "area 可用 DB、M、I、Q,S7-Smart200 还支持 V;area=DB 时 read.db 必须大于 0。"
  56. "该接口不解析业务值,只返回语义化 communication:S7_CONNECT、S7_CONNECTED、"
  57. "S7_READ、原始十六进制字节、S7_DISCONNECT 或 S7_ERROR。"
  58. "响应透传上游 JSON,code=0 表示业务成功,code!=0 时 msg 为失败原因。"
  59. ),
  60. )
  61. def s7_raw_read(
  62. project_key: str,
  63. ip: str,
  64. read: dict[str, Any],
  65. rock: int = 0,
  66. slot: int = 1,
  67. device_type: str = "S7-1200",
  68. port: int = 102,
  69. tsap_conn_type: str | None = None,
  70. ) -> dict[str, Any]:
  71. return api_s7_raw_read(
  72. project_key,
  73. ip=ip,
  74. rock=rock,
  75. slot=slot,
  76. read=read,
  77. device_type=device_type,
  78. port=port,
  79. tsap_conn_type=tsap_conn_type,
  80. )
  81. @mcp.tool(
  82. name="s7.point_collect_test",
  83. description=(
  84. "通过采集网关读取 Siemens S7 TCP 点位并转换为业务值。调用 "
  85. "{base_url}/api/dc-gateway/s7/read_points。请求字段使用网关/汇采一致命名:"
  86. "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
  87. "device_type 为设备类型,默认 S7-1200,可传 S7-1200、S7-Smart200。"
  88. "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
  89. "points 为网关读取格式的点位数组,每个点位必须包含 "
  90. "area、start、type;area 可用 DB、M、I、Q,S7-Smart200 还支持 V;"
  91. "area=DB 时 db 必须大于 0。"
  92. "type 可用 bool、byte、int8、int16、uint16、int32、uint32、int64、uint64、"
  93. "float32、float64;bool 点可传 bit 读取指定 0..7 位,非 bool 点不能传 bit。"
  94. "S7 多字节数值按大端解析,响应 data.points[].value 为转换后的业务值,"
  95. "data.communication 为语义化连接/读取/断开记录。响应透传上游 JSON,code=0 表示业务成功。"
  96. ),
  97. )
  98. def s7_point_collect_test(
  99. project_key: str,
  100. ip: str,
  101. rock: int,
  102. slot: int,
  103. points: list[dict[str, Any]],
  104. device_type: str = "S7-1200",
  105. port: int = 102,
  106. tsap_conn_type: str | None = None,
  107. ) -> dict[str, Any]:
  108. return api_s7_point_collect_test(
  109. project_key,
  110. ip=ip,
  111. rock=rock,
  112. slot=slot,
  113. points=points,
  114. device_type=device_type,
  115. port=port,
  116. tsap_conn_type=tsap_conn_type,
  117. )
  118. @mcp.tool(
  119. name="s7.connect_scan",
  120. description=(
  121. "通过采集网关扫描 Siemens S7 TCP 设备可用连接组合。调用 "
  122. "{base_url}/api/dc-gateway/s7/connect_scan。只需要传 PLC 的 ip,网关固定使用端口 102,"
  123. "扫描 rock=0/1/2、slot=0/1/2、tsap_conn_type=PG/OP/BASIC 共 27 种组合。"
  124. "响应 data.available 只返回可连接成功的组合,每项包含 rock、slot、tsap_conn_type;"
  125. "如果没有可用组合则 available 为空数组。响应透传上游 JSON,code=0 表示扫描完成。"
  126. ),
  127. )
  128. def s7_connect_scan(project_key: str, ip: str) -> dict[str, Any]:
  129. return api_s7_connect_scan(project_key, ip=ip)
  130. def _resolve_collector_s7_tsap_conn_type(device_type: int, tsap_conn_type: str | None) -> str:
  131. normalized_tsap = str(tsap_conn_type or "").strip().upper()
  132. if normalized_tsap:
  133. return normalized_tsap
  134. return "PG"
  135. @mcp.tool(
  136. name="collector.s7_device_create",
  137. description=(
  138. "汇采-批量创建 S7 设备。依次调用 {data_collector_base_url}/api/collector/device,"
  139. "type=s7。接收project_key和devices列表,"
  140. "devices 每项必须传 name 名称、ip IP 地址、rock 机架号(轨道号)、slot 槽号;"
  141. "port 默认 102,device_type 默认 1。device_type 必须使用数字枚举,不要传网关读取工具里的字符串:"
  142. "1=S7-1200, 2=S7-1500, 3=S7-Smart200。tsap_conn_type 可选 PG、OP、BASIC;"
  143. "未传时统一使用 PG;只有现场测试或扫描确认需要 OP/BASIC 时才显式传。"
  144. "默认参数: is_persistent=false, "
  145. "device_group_id=0, timeout=3, alarm_interval=90, collect_interval=5。"
  146. "从设备 CSV/网关结果转换时:S7-Smart200=>device_type=3;创建入参示例 "
  147. "{name:'plc1',ip:'192.168.1.10',port:102,rock:0,slot:1,device_type:3,tsap_conn_type:'PG'}。"
  148. "全部设备创建调用完成后会内部调用设备列表并匹配每个设备 id,"
  149. "返回批量结果,state=0 表示全部创建并匹配成功。"
  150. ),
  151. )
  152. def collector_s7_device_create(
  153. project_key: str,
  154. devices: list[S7DeviceCreateItem],
  155. ) -> dict[str, Any]:
  156. return api_create_s7_devices(project_key, [_dump_model_or_dict(item) for item in devices])
  157. @mcp.tool(
  158. name="collector.s7_device_edit",
  159. description=(
  160. "汇采-编辑 S7 设备。调用 {data_collector_base_url}/api/collector/s7/device/update。"
  161. "必须传 ori_id 原设备 id、name、ip、rock、slot;port 默认 102,device_type 默认 1,"
  162. "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
  163. "编辑前设备不能处于已连接状态;若已连接,请先调用 "
  164. "collector.device_disconnect,并传 device_type=s7。该接口是全量更新语义。"
  165. "默认参数: is_persistent=false, device_group_id=0, timeout=3, alarm_interval=90, "
  166. "collect_interval=5。device_type: 1=S7-1200, 2=S7-1500, 3=S7-Smart200。"
  167. "tsap_conn_type 可用 PG、OP、BASIC。响应透传上游 JSON,state=0 表示业务成功。"
  168. ),
  169. )
  170. def collector_s7_device_edit(
  171. project_key: str,
  172. ori_id: int,
  173. name: str,
  174. ip: str,
  175. rock: int,
  176. slot: int,
  177. port: int = 102,
  178. device_type: int = 1,
  179. tsap_conn_type: str | None = None,
  180. is_persistent: bool = False,
  181. device_group_id: int = 0,
  182. timeout: int = 3,
  183. alarm_interval: int = 90,
  184. collect_interval: int = 5,
  185. ) -> dict[str, Any]:
  186. return api_edit_s7_device(
  187. project_key,
  188. {
  189. "ori_id": ori_id,
  190. "name": name,
  191. "ip": ip,
  192. "rock": rock,
  193. "slot": slot,
  194. "port": port,
  195. "device_type": device_type,
  196. "tsap_conn_type": _resolve_collector_s7_tsap_conn_type(device_type, tsap_conn_type),
  197. "is_persistent": is_persistent,
  198. "device_group_id": device_group_id,
  199. "timeout": timeout,
  200. "alarm_interval": alarm_interval,
  201. "collect_interval": collect_interval,
  202. },
  203. )
  204. @mcp.tool(
  205. name="collector.s7_point_create",
  206. description=(
  207. "汇采-批量创建 S7 采集点位。依次调用 {data_collector_base_url}/api/collector/s7/point/add。"
  208. "points 每项必须传 device_id、name、address、data_type 或 type,"
  209. "以及 register_type 或 register_area。每个点位会补齐默认值: point_id='', "
  210. "scale_ratio=1, value_offset=0, group_Id=0, invalid_values='', valid_range_start=null, "
  211. "valid_range_end=null。register_type: 1=I输入区, 2=Q输出区, 3=M存储区, 4=DB数据块, "
  212. "5=V区, 6=AI模拟输入;register_area 可用 I、Q、M、DB、V、AI。"
  213. "data_type 可用 bool, uint8, int8, uint16, int16, uint32, int32, float32, float64。"
  214. "常见点表类型映射: BOOL=>bool, FLOAT/REAL=>float32, SHORT/INT=>int16, WORD=>uint16, "
  215. "DWORD=>uint32, DINT/LONG=>int32, DOUBLE/LREAL=>float64。"
  216. "S7 地址格式:I/Q/M/V 布尔点通常为 byte.bit,如 10.2;DB 布尔点为 db.byte.bit,"
  217. "如 1.10.2;非布尔点 I/Q/M/V 通常为 byte 地址,如 10,DB 为 db.byte,如 1.10。"
  218. "注意本工具是汇采创建格式,不是网关读取格式;不要传 area/start/bit。"
  219. "从常见 CSV 区域转换时:I=>register_area I;Q/QD=>Q;M/MD=>M;VD/VW=>V;"
  220. "DB/DBD/DBW/DBX=>DB。BOOL 点把地址列和位地址列合并为 address='byte.bit';"
  221. "DB BOOL 合并为 address='db.byte.bit';非 BOOL 使用字节地址,DB 非 BOOL 使用 'db.byte'。"
  222. "响应透传上游 JSON,state=0 表示业务成功。"
  223. ),
  224. )
  225. def collector_s7_point_create(
  226. project_key: str,
  227. points: list[S7PointCreateItem],
  228. ) -> dict[str, Any]:
  229. return api_create_s7_points(project_key, points)
  230. def _dump_model_or_dict(item: Any) -> dict[str, Any]:
  231. if isinstance(item, BaseModel):
  232. return item.model_dump(exclude_none=True)
  233. return dict(item)
  234. @mcp.tool(
  235. name="collector.s7_point_edit",
  236. description=(
  237. "汇采-编辑 S7 采集点位。调用 {data_collector_base_url}/api/collector/s7/point/update。"
  238. "必须传 ori_id 原点位 id、device_id 所属设备 id、name、address、data_type,"
  239. "以及 register_type 或 register_area。ori_id 对应 collector.device_points 返回的 data.point[].id。"
  240. "该接口是全量更新语义,未传字段可能被默认值覆盖。默认参数: point_id='', "
  241. "scale_ratio=1, value_offset=0, group_Id=0, invalid_values='', valid_range_start=null, "
  242. "valid_range_end=null。register_type: 1=I, 2=Q, 3=M, 4=DB, 5=V, 6=AI。"
  243. "data_type 可用 bool, uint8, int8, uint16, int16, uint32, int32, float32, float64。"
  244. "响应透传上游 JSON,state=0 表示业务成功。"
  245. ),
  246. )
  247. def collector_s7_point_edit(
  248. project_key: str,
  249. ori_id: int,
  250. device_id: int,
  251. name: str,
  252. address: str,
  253. data_type: str,
  254. register_type: int = 0,
  255. register_area: str = "",
  256. point_id: str = "",
  257. scale_ratio: float = 1,
  258. value_offset: float = 0,
  259. group_id: int = 0,
  260. invalid_values: str = "",
  261. valid_range_start: float | None = None,
  262. valid_range_end: float | None = None,
  263. describe: str = "",
  264. ) -> dict[str, Any]:
  265. payload: dict[str, Any] = {
  266. "ori_id": ori_id,
  267. "device_id": device_id,
  268. "name": name,
  269. "address": address,
  270. "data_type": data_type,
  271. "point_id": point_id,
  272. "scale_ratio": scale_ratio,
  273. "value_offset": value_offset,
  274. "group_id": group_id,
  275. "invalid_values": invalid_values,
  276. "valid_range_start": valid_range_start,
  277. "valid_range_end": valid_range_end,
  278. "describe": describe,
  279. }
  280. if register_type:
  281. payload["register_type"] = register_type
  282. else:
  283. payload["register_area"] = register_area
  284. return api_edit_s7_point(project_key, payload)