test_collector_api.py 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. from __future__ import annotations
  2. import unittest
  3. from unittest.mock import patch
  4. from data_collector_mcp import collector_api
  5. class CollectorApiTests(unittest.TestCase):
  6. def _patch_project(self):
  7. patches = [
  8. patch(
  9. "data_collector_mcp.collector_api.find_project_config",
  10. return_value={
  11. "project_key": "dev-01",
  12. "data_collector_base_url": "http://collector.test",
  13. },
  14. ),
  15. patch(
  16. "data_collector_mcp.collector_api.resolve_project_token",
  17. return_value="token",
  18. ),
  19. ]
  20. for item in patches:
  21. item.start()
  22. self.addCleanup(item.stop)
  23. def test_create_modbus_device_merges_defaults_and_posts_to_collector(self) -> None:
  24. self._patch_project()
  25. response = {"state": 0, "state_info": "成功"}
  26. with patch(
  27. "data_collector_mcp.collector_api.request_json",
  28. return_value=response,
  29. ) as request_json:
  30. result = collector_api.create_modbus_device(
  31. "dev-01",
  32. {
  33. "name": "modbus_tcp_1",
  34. "device_type": 1,
  35. "ip": "127.0.0.1",
  36. "port": 5502,
  37. "slave_id": 1,
  38. "byte_order": 2,
  39. "word_order": 2,
  40. "address_base": 1,
  41. },
  42. )
  43. self.assertEqual(result, response)
  44. request_json.assert_called_once()
  45. self.assertEqual(request_json.call_args.args[:3], ("POST", "http://collector.test/api/collector/device", "token"))
  46. payload = request_json.call_args.kwargs["json_payload"]
  47. self.assertEqual(payload["type"], "modbus")
  48. self.assertEqual(payload["device_type"], 1)
  49. self.assertEqual(payload["timeout"], 3)
  50. self.assertEqual(payload["alarm_interval"], 90)
  51. self.assertEqual(payload["collect_interval"], 5)
  52. self.assertEqual(payload["byte_order"], 2)
  53. self.assertEqual(payload["word_order"], 2)
  54. self.assertEqual(payload["address_offset"], 1)
  55. self.assertNotIn("address_base", payload)
  56. self.assertEqual(payload["name"], "modbus_tcp_1")
  57. def test_create_modbus_device_requires_required_fields(self) -> None:
  58. self._patch_project()
  59. required_fields = [
  60. "name",
  61. "device_type",
  62. "ip",
  63. "port",
  64. "slave_id",
  65. "word_order",
  66. "byte_order",
  67. "address_base",
  68. ]
  69. base_payload = {
  70. "name": "modbus_tcp_1",
  71. "device_type": 1,
  72. "ip": "127.0.0.1",
  73. "port": 5502,
  74. "slave_id": 1,
  75. "byte_order": 1,
  76. "word_order": 1,
  77. "address_base": 0,
  78. }
  79. for field_name in required_fields:
  80. with self.subTest(field_name=field_name):
  81. payload = dict(base_payload)
  82. payload.pop(field_name)
  83. with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
  84. collector_api.create_modbus_device("dev-01", payload)
  85. def test_create_modbus_devices_batches_then_matches_largest_id(self) -> None:
  86. self._patch_project()
  87. devices = [
  88. {
  89. "name": "modbus_tcp_1",
  90. "device_type": 1,
  91. "ip": "127.0.0.1",
  92. "port": 5502,
  93. "slave_id": 1,
  94. "byte_order": 1,
  95. "word_order": 1,
  96. "address_base": 0,
  97. "group_id": 10,
  98. },
  99. {
  100. "name": "modbus_tcp_2",
  101. "device_type": 1,
  102. "ip": "127.0.0.2",
  103. "port": 5503,
  104. "slave_id": 2,
  105. "byte_order": 1,
  106. "word_order": 1,
  107. "address_base": 0,
  108. "group_id": 10,
  109. },
  110. ]
  111. device_list = {
  112. "state": 0,
  113. "devices": [
  114. {
  115. "id": 10,
  116. "name": "group",
  117. "type": "devicegroup",
  118. "groups": [
  119. {
  120. "id": 3,
  121. "name": "modbus_tcp_1",
  122. "type": "modbus",
  123. "device_type": 1,
  124. "ip": "127.0.0.1",
  125. "port": 5502,
  126. "slave_id": 1,
  127. "group_id": 10,
  128. "address_offset": 0,
  129. },
  130. {
  131. "id": 9,
  132. "name": "modbus_tcp_1",
  133. "type": "modbus",
  134. "device_type": 1,
  135. "ip": "127.0.0.1",
  136. "port": 5502,
  137. "slave_id": 1,
  138. "group_id": 10,
  139. "address_offset": 0,
  140. },
  141. {
  142. "id": 11,
  143. "name": "modbus_tcp_2",
  144. "type": "modbus",
  145. "device_type": 1,
  146. "ip": "127.0.0.2",
  147. "port": 5503,
  148. "slave_id": 2,
  149. "group_id": 10,
  150. "address_offset": 0,
  151. },
  152. ],
  153. }
  154. ],
  155. }
  156. with patch(
  157. "data_collector_mcp.collector_api.request_json",
  158. side_effect=[{"state": 0}, {"state": 0}, device_list],
  159. ) as request_json:
  160. result = collector_api.create_modbus_devices("dev-01", devices)
  161. self.assertEqual(result["state"], 0)
  162. self.assertEqual(result["summary"], {"total": 2, "created": 2, "matched": 2, "failed": 0})
  163. self.assertEqual(result["results"][0]["device_id"], 9)
  164. self.assertEqual(result["results"][1]["device_id"], 11)
  165. self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/device")
  166. self.assertEqual(request_json.call_args_list[1].args[1], "http://collector.test/api/collector/device")
  167. self.assertEqual(request_json.call_args_list[2].args[1], "http://collector.test/api/collector/device?num_points=false")
  168. def test_create_modbus_points_batches_requests(self) -> None:
  169. self._patch_project()
  170. points = [
  171. {"device_id": 9, "name": "temperature", "func_code": 3, "address": 10, "type": "uint16"},
  172. {"device_id": 9, "name": "pressure", "func_code": 3, "address": 11, "type": "uint16"},
  173. ]
  174. with patch(
  175. "data_collector_mcp.collector_api.request_json",
  176. side_effect=[{"state": 0}, {"state": 0}],
  177. ) as request_json:
  178. result = collector_api.create_modbus_points("dev-01", points)
  179. self.assertEqual(result["state"], 0)
  180. self.assertEqual(result["summary"], {"total": 2, "success": 2, "failed": 0})
  181. self.assertEqual(request_json.call_count, 2)
  182. self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/modbus/point/add_collect_point")
  183. def test_edit_modbus_device_maps_aliases_and_posts_to_legacy_endpoint(self) -> None:
  184. self._patch_project()
  185. response = {"state": 0, "state_info": "操作成功", "data": None}
  186. with patch(
  187. "data_collector_mcp.collector_api.request_json",
  188. return_value=response,
  189. ) as request_json:
  190. result = collector_api.edit_modbus_device(
  191. "dev-01",
  192. {
  193. "ori_id": 1,
  194. "name": "modbus_tcp_edited",
  195. "device_type": 1,
  196. "ip": "127.0.0.1",
  197. "port": 5502,
  198. "slave_id": 1,
  199. "byte_order": 2,
  200. "word_order": 2,
  201. "address_base": 1,
  202. "group_id": 10,
  203. },
  204. )
  205. self.assertEqual(result, response)
  206. self.assertEqual(
  207. request_json.call_args.args[:3],
  208. (
  209. "POST",
  210. "http://collector.test/api/collector/modbus/device/edit",
  211. "token",
  212. ),
  213. )
  214. payload = request_json.call_args.kwargs["json_payload"]
  215. self.assertEqual(payload["ori_id"], 1)
  216. self.assertEqual(payload["type"], 1)
  217. self.assertEqual(payload["address_offset"], 1)
  218. self.assertEqual(payload["device_group_id"], 10)
  219. self.assertEqual(payload["timeout"], 3)
  220. self.assertEqual(payload["alarm_interval"], 90)
  221. self.assertEqual(payload["collect_interval"], 5)
  222. self.assertEqual(payload["retry_times"], 0)
  223. self.assertEqual(payload["mode"], 0)
  224. self.assertNotIn("device_type", payload)
  225. self.assertNotIn("address_base", payload)
  226. self.assertNotIn("group_id", payload)
  227. def test_edit_modbus_device_supports_rtu_serial_port(self) -> None:
  228. self._patch_project()
  229. with patch(
  230. "data_collector_mcp.collector_api.request_json",
  231. return_value={"state": 0},
  232. ) as request_json:
  233. collector_api.edit_modbus_device(
  234. "dev-01",
  235. {
  236. "ori_id": 1,
  237. "name": "modbus_rtu_edited",
  238. "type": 2,
  239. "serial_port": "COM3",
  240. "slave_id": 1,
  241. "byte_order": 1,
  242. "word_order": 1,
  243. },
  244. )
  245. payload = request_json.call_args.kwargs["json_payload"]
  246. self.assertEqual(payload["type"], 2)
  247. self.assertEqual(payload["serial_port"], "COM3")
  248. def test_edit_modbus_device_requires_required_fields(self) -> None:
  249. self._patch_project()
  250. base_payload = {
  251. "ori_id": 1,
  252. "name": "modbus_tcp_edited",
  253. "device_type": 1,
  254. "ip": "127.0.0.1",
  255. "port": 5502,
  256. "slave_id": 1,
  257. "byte_order": 1,
  258. "word_order": 1,
  259. }
  260. required_fields = ["ori_id", "name", "device_type", "ip", "port", "slave_id", "byte_order", "word_order"]
  261. for field_name in required_fields:
  262. with self.subTest(field_name=field_name):
  263. payload = dict(base_payload)
  264. payload.pop(field_name)
  265. with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
  266. collector_api.edit_modbus_device("dev-01", payload)
  267. def test_edit_modbus_device_requires_serial_port_for_rtu(self) -> None:
  268. self._patch_project()
  269. with self.assertRaisesRegex(ValueError, "payload.serial_port is required"):
  270. collector_api.edit_modbus_device(
  271. "dev-01",
  272. {
  273. "ori_id": 1,
  274. "name": "modbus_rtu_edited",
  275. "type": 2,
  276. "slave_id": 1,
  277. "byte_order": 1,
  278. "word_order": 1,
  279. },
  280. )
  281. def test_edit_modbus_device_rejects_invalid_connection_type(self) -> None:
  282. self._patch_project()
  283. with self.assertRaisesRegex(ValueError, "payload.type must be one of 1, 2, 3, 4, 5"):
  284. collector_api.edit_modbus_device(
  285. "dev-01",
  286. {
  287. "ori_id": 1,
  288. "name": "modbus_tcp_edited",
  289. "type": 0,
  290. "ip": "127.0.0.1",
  291. "port": 5502,
  292. "slave_id": 1,
  293. "byte_order": 1,
  294. "word_order": 1,
  295. },
  296. )
  297. def test_create_modbus_point_merges_defaults_and_posts_to_collector(self) -> None:
  298. self._patch_project()
  299. response = {"state": 0, "state_info": "成功", "data": None}
  300. with patch(
  301. "data_collector_mcp.collector_api.request_json",
  302. return_value=response,
  303. ) as request_json:
  304. result = collector_api.create_modbus_point(
  305. "dev-01",
  306. {
  307. "device_id": 1,
  308. "name": "holding_register_uint16",
  309. "point_id": "HR_UINT16",
  310. "func_code": 3,
  311. "address": 10,
  312. "type": "uint16",
  313. },
  314. )
  315. self.assertEqual(result, response)
  316. self.assertEqual(
  317. request_json.call_args.args[:3],
  318. (
  319. "POST",
  320. "http://collector.test/api/collector/modbus/point/add_collect_point",
  321. "token",
  322. ),
  323. )
  324. payload = request_json.call_args.kwargs["json_payload"]
  325. self.assertEqual(payload["point_id"], "HR_UINT16")
  326. self.assertEqual(payload["scale_ratio"], 1)
  327. self.assertEqual(payload["value_offset"], 0)
  328. self.assertEqual(payload["group_id"], 0)
  329. self.assertEqual(payload["invalid_values"], "")
  330. self.assertIsNone(payload["valid_range_start"])
  331. self.assertIsNone(payload["valid_range_end"])
  332. self.assertEqual(payload["bit"], 0)
  333. self.assertEqual(payload["func_code"], 3)
  334. def test_create_modbus_point_defaults_point_id_to_empty_string(self) -> None:
  335. self._patch_project()
  336. with patch(
  337. "data_collector_mcp.collector_api.request_json",
  338. return_value={"state": 0},
  339. ) as request_json:
  340. collector_api.create_modbus_point(
  341. "dev-01",
  342. {
  343. "device_id": 1,
  344. "name": "holding_register_uint16",
  345. "func_code": 3,
  346. "address": 10,
  347. "type": "uint16",
  348. },
  349. )
  350. payload = request_json.call_args.kwargs["json_payload"]
  351. self.assertEqual(payload["point_id"], "")
  352. def test_create_modbus_point_normalizes_type_alias_and_register_type(self) -> None:
  353. self._patch_project()
  354. with patch(
  355. "data_collector_mcp.collector_api.request_json",
  356. return_value={"state": 0},
  357. ) as request_json:
  358. collector_api.create_modbus_point(
  359. "dev-01",
  360. {
  361. "device_id": 1,
  362. "name": "temperature",
  363. "point_id": "TEMP",
  364. "register_type": "holding_register",
  365. "address": 10,
  366. "type": "SHORT",
  367. },
  368. )
  369. payload = request_json.call_args.kwargs["json_payload"]
  370. self.assertEqual(payload["func_code"], 3)
  371. self.assertEqual(payload["type"], "int16")
  372. self.assertNotIn("register_type", payload)
  373. def test_create_modbus_point_requires_name(self) -> None:
  374. self._patch_project()
  375. with self.assertRaisesRegex(ValueError, "payload.name is required"):
  376. collector_api.create_modbus_point(
  377. "dev-01",
  378. {
  379. "device_id": 1,
  380. "func_code": 3,
  381. "address": 10,
  382. "type": "int16",
  383. },
  384. )
  385. def test_create_modbus_point_requires_register_type_or_func_code(self) -> None:
  386. self._patch_project()
  387. with self.assertRaisesRegex(ValueError, "payload.register_type is required"):
  388. collector_api.create_modbus_point(
  389. "dev-01",
  390. {
  391. "device_id": 1,
  392. "name": "temperature",
  393. "address": 10,
  394. "type": "int16",
  395. },
  396. )
  397. def test_create_modbus_point_requires_address(self) -> None:
  398. self._patch_project()
  399. with self.assertRaisesRegex(ValueError, "payload.address is required"):
  400. collector_api.create_modbus_point(
  401. "dev-01",
  402. {
  403. "device_id": 1,
  404. "name": "temperature",
  405. "func_code": 3,
  406. "type": "int16",
  407. },
  408. )
  409. def test_create_modbus_point_rejects_unknown_type(self) -> None:
  410. self._patch_project()
  411. with self.assertRaisesRegex(ValueError, "payload.type is invalid"):
  412. collector_api.create_modbus_point(
  413. "dev-01",
  414. {
  415. "device_id": 1,
  416. "name": "temperature",
  417. "func_code": 3,
  418. "address": 10,
  419. "type": "SHORT_REAL",
  420. },
  421. )
  422. def test_edit_modbus_point_merges_defaults_and_posts_to_collector(self) -> None:
  423. self._patch_project()
  424. response = {"state": 0, "state_info": "成功", "data": None}
  425. with patch(
  426. "data_collector_mcp.collector_api.request_json",
  427. return_value=response,
  428. ) as request_json:
  429. result = collector_api.edit_modbus_point(
  430. "dev-01",
  431. {
  432. "ori_id": 101,
  433. "name": "holding_register_uint16_edited",
  434. "point_id": "HR_UINT16_EDITED",
  435. "register_type": "holding_register",
  436. "address": 10,
  437. "type": "WORD",
  438. },
  439. )
  440. self.assertEqual(result, response)
  441. self.assertEqual(
  442. request_json.call_args.args[:3],
  443. (
  444. "POST",
  445. "http://collector.test/api/collector/modbus/point/edit_collect_point",
  446. "token",
  447. ),
  448. )
  449. payload = request_json.call_args.kwargs["json_payload"]
  450. self.assertEqual(payload["ori_id"], 101)
  451. self.assertEqual(payload["point_id"], "HR_UINT16_EDITED")
  452. self.assertEqual(payload["func_code"], 3)
  453. self.assertEqual(payload["type"], "uint16")
  454. self.assertEqual(payload["scale_ratio"], 1)
  455. self.assertEqual(payload["value_offset"], 0)
  456. self.assertEqual(payload["group_id"], 0)
  457. self.assertEqual(payload["invalid_values"], "")
  458. self.assertIsNone(payload["valid_range_start"])
  459. self.assertIsNone(payload["valid_range_end"])
  460. self.assertEqual(payload["bit"], 0)
  461. self.assertNotIn("register_type", payload)
  462. def test_edit_modbus_point_defaults_point_id_to_empty_string(self) -> None:
  463. self._patch_project()
  464. with patch(
  465. "data_collector_mcp.collector_api.request_json",
  466. return_value={"state": 0},
  467. ) as request_json:
  468. collector_api.edit_modbus_point(
  469. "dev-01",
  470. {
  471. "ori_id": 101,
  472. "name": "holding_register_uint16_edited",
  473. "func_code": 3,
  474. "address": 10,
  475. "type": "uint16",
  476. },
  477. )
  478. payload = request_json.call_args.kwargs["json_payload"]
  479. self.assertEqual(payload["point_id"], "")
  480. def test_edit_modbus_point_requires_ori_id(self) -> None:
  481. self._patch_project()
  482. with self.assertRaisesRegex(ValueError, "payload.ori_id is required"):
  483. collector_api.edit_modbus_point(
  484. "dev-01",
  485. {
  486. "name": "holding_register_uint16_edited",
  487. "func_code": 3,
  488. "address": 10,
  489. "type": "uint16",
  490. },
  491. )
  492. def test_create_s7_device_merges_defaults_and_posts_to_collector(self) -> None:
  493. self._patch_project()
  494. response = {"state": 0, "state_info": "成功"}
  495. with patch(
  496. "data_collector_mcp.collector_api.request_json",
  497. return_value=response,
  498. ) as request_json:
  499. result = collector_api.create_s7_device(
  500. "dev-01",
  501. {
  502. "name": "s7_1200_1",
  503. "ip": "127.0.0.1",
  504. "rock": 0,
  505. "slot": 1,
  506. "group_id": 10,
  507. },
  508. )
  509. self.assertEqual(result, response)
  510. self.assertEqual(
  511. request_json.call_args.args[:3],
  512. (
  513. "POST",
  514. "http://collector.test/api/collector/device",
  515. "token",
  516. ),
  517. )
  518. payload = request_json.call_args.kwargs["json_payload"]
  519. self.assertEqual(payload["type"], "s7")
  520. self.assertEqual(payload["device_type"], 1)
  521. self.assertEqual(payload["port"], 102)
  522. self.assertEqual(payload["tsap_conn_type"], "PG")
  523. self.assertEqual(payload["device_group_id"], 10)
  524. self.assertEqual(payload["rock"], 0)
  525. self.assertEqual(payload["slot"], 1)
  526. self.assertNotIn("group_id", payload)
  527. def test_create_s7_device_defaults_smart200_tsap_to_pg(self) -> None:
  528. self._patch_project()
  529. with patch(
  530. "data_collector_mcp.collector_api.request_json",
  531. return_value={"state": 0},
  532. ) as request_json:
  533. collector_api.create_s7_device(
  534. "dev-01",
  535. {
  536. "name": "smart200_1",
  537. "ip": "127.0.0.1",
  538. "rock": 0,
  539. "slot": 1,
  540. "device_type": 3,
  541. },
  542. )
  543. payload = request_json.call_args.kwargs["json_payload"]
  544. self.assertEqual(payload["device_type"], 3)
  545. self.assertEqual(payload["tsap_conn_type"], "PG")
  546. def test_create_s7_devices_batches_then_matches_largest_id(self) -> None:
  547. self._patch_project()
  548. devices = [
  549. {
  550. "name": "s7_1200_1",
  551. "ip": "127.0.0.1",
  552. "rock": 0,
  553. "slot": 1,
  554. "device_group_id": 10,
  555. }
  556. ]
  557. device_list = {
  558. "state": 0,
  559. "devices": [
  560. {
  561. "id": 5,
  562. "name": "s7_1200_1",
  563. "type": "s7",
  564. "ip": "127.0.0.1",
  565. "port": 102,
  566. "rock": 0,
  567. "slot": 1,
  568. "device_type": 1,
  569. "tsap_conn_type": "PG",
  570. "group_id": 10,
  571. },
  572. {
  573. "id": 8,
  574. "name": "s7_1200_1",
  575. "type": "s7",
  576. "ip": "127.0.0.1",
  577. "port": 102,
  578. "rock": 0,
  579. "slot": 1,
  580. "device_type": 1,
  581. "tsap_conn_type": "PG",
  582. "group_id": 10,
  583. },
  584. ],
  585. }
  586. with patch(
  587. "data_collector_mcp.collector_api.request_json",
  588. side_effect=[{"state": 0}, device_list],
  589. ) as request_json:
  590. result = collector_api.create_s7_devices("dev-01", devices)
  591. self.assertEqual(result["state"], 0)
  592. self.assertEqual(result["summary"], {"total": 1, "created": 1, "matched": 1, "failed": 0})
  593. self.assertEqual(result["results"][0]["device_id"], 8)
  594. self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/device")
  595. self.assertEqual(request_json.call_args_list[1].args[1], "http://collector.test/api/collector/device?num_points=false")
  596. def test_create_s7_points_batches_requests(self) -> None:
  597. self._patch_project()
  598. points = [
  599. {"device_id": 8, "name": "db_real", "register_area": "DB", "address": "1.10", "type": "REAL"},
  600. {"device_id": 8, "name": "m_bool", "register_area": "M", "address": "10.2", "type": "BOOL"},
  601. ]
  602. with patch(
  603. "data_collector_mcp.collector_api.request_json",
  604. side_effect=[{"state": 0}, {"state": 0}],
  605. ) as request_json:
  606. result = collector_api.create_s7_points("dev-01", points)
  607. self.assertEqual(result["state"], 0)
  608. self.assertEqual(result["summary"], {"total": 2, "success": 2, "failed": 0})
  609. self.assertEqual(request_json.call_count, 2)
  610. self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/s7/point/add")
  611. def test_edit_s7_device_uses_id_and_posts_to_update_endpoint(self) -> None:
  612. self._patch_project()
  613. with patch(
  614. "data_collector_mcp.collector_api.request_json",
  615. return_value={"state": 0},
  616. ) as request_json:
  617. collector_api.edit_s7_device(
  618. "dev-01",
  619. {
  620. "id": 3,
  621. "name": "s7_edited",
  622. "ip": "127.0.0.1",
  623. "rock": 0,
  624. "slot": 2,
  625. "tsap_conn_type": "op",
  626. },
  627. )
  628. self.assertEqual(request_json.call_args.args[1], "http://collector.test/api/collector/s7/device/update")
  629. payload = request_json.call_args.kwargs["json_payload"]
  630. self.assertEqual(payload["id"], 3)
  631. self.assertEqual(payload["tsap_conn_type"], "OP")
  632. self.assertNotIn("ori_id", payload)
  633. def test_create_s7_point_normalizes_aliases_and_posts_to_collector(self) -> None:
  634. self._patch_project()
  635. with patch(
  636. "data_collector_mcp.collector_api.request_json",
  637. return_value={"state": 0},
  638. ) as request_json:
  639. collector_api.create_s7_point(
  640. "dev-01",
  641. {
  642. "device_id": 3,
  643. "name": "db_real",
  644. "point_id": "DB_REAL",
  645. "register_area": "DB",
  646. "address": "1.10",
  647. "type": "REAL",
  648. "group_id": 5,
  649. },
  650. )
  651. self.assertEqual(request_json.call_args.args[1], "http://collector.test/api/collector/s7/point/add")
  652. payload = request_json.call_args.kwargs["json_payload"]
  653. self.assertEqual(payload["register_type"], 4)
  654. self.assertEqual(payload["data_type"], "float32")
  655. self.assertEqual(payload["group_Id"], 5)
  656. self.assertEqual(payload["scale_ratio"], 1)
  657. self.assertNotIn("type", payload)
  658. self.assertNotIn("register_area", payload)
  659. self.assertNotIn("group_id", payload)
  660. def test_edit_s7_point_uses_id_and_requires_device_id(self) -> None:
  661. self._patch_project()
  662. with patch(
  663. "data_collector_mcp.collector_api.request_json",
  664. return_value={"state": 0},
  665. ) as request_json:
  666. collector_api.edit_s7_point(
  667. "dev-01",
  668. {
  669. "id": 101,
  670. "device_id": 3,
  671. "name": "m_bool",
  672. "register_type": 3,
  673. "address": "10.2",
  674. "data_type": "BOOL",
  675. },
  676. )
  677. self.assertEqual(request_json.call_args.args[1], "http://collector.test/api/collector/s7/point/update")
  678. payload = request_json.call_args.kwargs["json_payload"]
  679. self.assertEqual(payload["id"], 101)
  680. self.assertEqual(payload["data_type"], "bool")
  681. self.assertEqual(payload["register_type"], 3)
  682. self.assertEqual(payload["group_Id"], 0)
  683. self.assertNotIn("ori_id", payload)
  684. with self.assertRaisesRegex(ValueError, "payload.device_id is required"):
  685. collector_api.edit_s7_point(
  686. "dev-01",
  687. {
  688. "id": 101,
  689. "name": "m_bool",
  690. "register_type": 3,
  691. "address": "10.2",
  692. "data_type": "bool",
  693. },
  694. )
  695. def test_list_devices_defaults_num_points_false(self) -> None:
  696. self._patch_project()
  697. response = {"state": 0, "devices": []}
  698. with patch(
  699. "data_collector_mcp.collector_api.request_json",
  700. return_value=response,
  701. ) as request_json:
  702. result = collector_api.list_devices("dev-01")
  703. self.assertEqual(result, response)
  704. request_json.assert_called_once_with(
  705. "GET",
  706. "http://collector.test/api/collector/device?num_points=false",
  707. "token",
  708. json_payload=None,
  709. )
  710. def test_list_devices_can_enable_num_points(self) -> None:
  711. self._patch_project()
  712. with patch(
  713. "data_collector_mcp.collector_api.request_json",
  714. return_value={"state": 0},
  715. ) as request_json:
  716. collector_api.list_devices("dev-01", num_points=True)
  717. self.assertEqual(
  718. request_json.call_args.args[1],
  719. "http://collector.test/api/collector/device?num_points=true",
  720. )
  721. def test_connect_device_posts_connected_status(self) -> None:
  722. self._patch_project()
  723. response = {"state": 0, "data": {"status": 2, "running_status": 0}}
  724. with patch(
  725. "data_collector_mcp.collector_api.request_json",
  726. return_value=response,
  727. ) as request_json:
  728. result = collector_api.connect_device("dev-01", device_id=1, device_type="modbus")
  729. self.assertEqual(result, response)
  730. request_json.assert_called_once_with(
  731. "POST",
  732. "http://collector.test/api/collector/common/device/set_connect_status",
  733. "token",
  734. json_payload={"id": 1, "type": "modbus", "status": 2},
  735. )
  736. def test_disconnect_device_posts_disconnected_status(self) -> None:
  737. self._patch_project()
  738. response = {"state": 0, "data": {"status": 1, "running_status": 0}}
  739. with patch(
  740. "data_collector_mcp.collector_api.request_json",
  741. return_value=response,
  742. ) as request_json:
  743. result = collector_api.disconnect_device("dev-01", device_id=1, device_type="modbus")
  744. self.assertEqual(result, response)
  745. request_json.assert_called_once_with(
  746. "POST",
  747. "http://collector.test/api/collector/common/device/set_connect_status",
  748. "token",
  749. json_payload={"id": 1, "type": "modbus", "status": 1},
  750. )
  751. def test_list_device_points_posts_device_and_group(self) -> None:
  752. self._patch_project()
  753. response = {"state": 0, "data": {"point": [], "total": 0}}
  754. with patch(
  755. "data_collector_mcp.collector_api.request_json",
  756. return_value=response,
  757. ) as request_json:
  758. result = collector_api.list_device_points(
  759. "dev-01",
  760. device_id=1,
  761. device_type="modbus",
  762. group_id=100,
  763. )
  764. self.assertEqual(result, response)
  765. request_json.assert_called_once_with(
  766. "POST",
  767. "http://collector.test/api/collector/common/device/get_collect_point",
  768. "token",
  769. json_payload={"id": 1, "type": "modbus", "group_id": 100},
  770. )
  771. def test_device_common_tools_validate_ids(self) -> None:
  772. self._patch_project()
  773. with self.assertRaisesRegex(ValueError, "device_id must be a positive integer"):
  774. collector_api.connect_device("dev-01", device_id=0)
  775. with self.assertRaisesRegex(ValueError, "group_id must be a non-negative integer"):
  776. collector_api.list_device_points("dev-01", device_id=1, group_id=-1)
  777. with self.assertRaisesRegex(ValueError, "device_type is required"):
  778. collector_api.disconnect_device("dev-01", device_id=1, device_type="")
  779. def test_create_bacnet_device_merges_defaults_and_posts_to_collector(self) -> None:
  780. self._patch_project()
  781. response = {"state": 0, "state_info": "成功"}
  782. with patch(
  783. "data_collector_mcp.collector_api.request_json",
  784. return_value=response,
  785. ) as request_json:
  786. result = collector_api.create_bacnet_device(
  787. "dev-01",
  788. {
  789. "name": "bacnet_1",
  790. "ip": "192.168.1.20",
  791. "bacnet_device_id": 12345,
  792. },
  793. )
  794. self.assertEqual(result, response)
  795. self.assertEqual(request_json.call_args.args[:3], ("POST", "http://collector.test/api/collector/device", "token"))
  796. payload = request_json.call_args.kwargs["json_payload"]
  797. self.assertEqual(payload["type"], "bacnet")
  798. self.assertEqual(payload["device_type"], 1)
  799. self.assertEqual(payload["port"], 47808)
  800. self.assertEqual(payload["bacnet_net"], 0)
  801. self.assertEqual(payload["asp_ip"], "")
  802. self.assertEqual(payload["timeout"], 3)
  803. self.assertFalse(payload["is_persistent"])
  804. self.assertEqual(payload["group_id"], 0)
  805. self.assertEqual(payload["alarm_interval"], 90)
  806. self.assertEqual(payload["collect_interval"], 5)
  807. def test_create_bacnet_devices_batches_then_matches_largest_id(self) -> None:
  808. self._patch_project()
  809. devices = [{"name": "bacnet_1", "ip": "192.168.1.20", "bacnet_device_id": 12345, "group_id": 10}]
  810. device_list = {
  811. "state": 0,
  812. "devices": [
  813. {
  814. "id": 1,
  815. "name": "group",
  816. "type": "devicegroup",
  817. "groups": [
  818. {
  819. "id": 3,
  820. "name": "bacnet_1",
  821. "type": "bacnet",
  822. "device_type": 1,
  823. "ip": "192.168.1.20",
  824. "port": 47808,
  825. "bacnet_device_id": 12345,
  826. "bacnet_net": 0,
  827. "group_id": 10,
  828. },
  829. {
  830. "id": 9,
  831. "name": "bacnet_1",
  832. "type": "bacnet",
  833. "device_type": 1,
  834. "ip": "192.168.1.20",
  835. "port": 47808,
  836. "bacnet_device_id": 12345,
  837. "bacnet_net": 0,
  838. "group_id": 10,
  839. },
  840. ],
  841. }
  842. ],
  843. }
  844. with patch(
  845. "data_collector_mcp.collector_api.request_json",
  846. side_effect=[{"state": 0}, device_list],
  847. ):
  848. result = collector_api.create_bacnet_devices("dev-01", devices)
  849. self.assertEqual(result["state"], 0)
  850. self.assertEqual(result["summary"], {"total": 1, "created": 1, "matched": 1, "failed": 0})
  851. self.assertEqual(result["results"][0]["device_id"], 9)
  852. def test_edit_bacnet_device_maps_to_legacy_endpoint(self) -> None:
  853. self._patch_project()
  854. response = {"state": 0, "state_info": "成功"}
  855. with patch(
  856. "data_collector_mcp.collector_api.request_json",
  857. return_value=response,
  858. ) as request_json:
  859. result = collector_api.edit_bacnet_device(
  860. "dev-01",
  861. {
  862. "ori_id": 9,
  863. "name": "bacnet_edited",
  864. "ip": "192.168.1.21",
  865. "bacnet_device_id": 54321,
  866. "bacnet_net": 2,
  867. "device_group_id": 10,
  868. },
  869. )
  870. self.assertEqual(result, response)
  871. self.assertEqual(
  872. request_json.call_args.args[:3],
  873. ("POST", "http://collector.test/api/collector/bacnet/device/edit", "token"),
  874. )
  875. payload = request_json.call_args.kwargs["json_payload"]
  876. self.assertEqual(payload["ori_id"], 9)
  877. self.assertEqual(payload["type"], 1)
  878. self.assertEqual(payload["device_id"], "54321")
  879. self.assertEqual(payload["net"], 2)
  880. self.assertEqual(payload["device_group_id"], 10)
  881. self.assertNotIn("bacnet_device_id", payload)
  882. self.assertNotIn("bacnet_net", payload)
  883. def test_create_bacnet_point_wraps_single_point_batch_payload(self) -> None:
  884. self._patch_project()
  885. response = {"state": 0, "state_info": "成功"}
  886. with patch(
  887. "data_collector_mcp.collector_api.request_json",
  888. return_value=response,
  889. ) as request_json:
  890. result = collector_api.create_bacnet_point(
  891. "dev-01",
  892. {
  893. "device_id": 9,
  894. "name": "zone_temperature",
  895. "object_type": "AnalogInput",
  896. "object_id": 1,
  897. "point_id": "AI_TEMP",
  898. "value_type": 4,
  899. "invalid_values": "-9999",
  900. "valid_range_start": 0,
  901. "valid_range_end": 100,
  902. },
  903. )
  904. self.assertEqual(result, response)
  905. self.assertEqual(
  906. request_json.call_args.args[:3],
  907. ("POST", "http://collector.test/api/collector/bacnet/point/add_collect_point", "token"),
  908. )
  909. payload = request_json.call_args.kwargs["json_payload"]
  910. self.assertEqual(payload["device_id"], 9)
  911. self.assertEqual(len(payload["points"]), 1)
  912. point = payload["points"][0]
  913. self.assertEqual(point["object_name"], "zone_temperature")
  914. self.assertEqual(point["name"], "zone_temperature")
  915. self.assertEqual(point["object_type"], "AnalogInput")
  916. self.assertEqual(point["object_id"], 1)
  917. self.assertEqual(point["point_id"], "AI_TEMP")
  918. self.assertEqual(point["value_type"], 4)
  919. self.assertEqual(point["scale_ratio"], 1)
  920. self.assertEqual(point["invalid_values"], "-9999")
  921. self.assertEqual(point["valid_range_start"], 0)
  922. self.assertEqual(point["valid_range_end"], 100)
  923. def test_bacnet_point_payloads_normalize_object_type(self) -> None:
  924. self._patch_project()
  925. with patch(
  926. "data_collector_mcp.collector_api.request_json",
  927. return_value={"state": 0},
  928. ) as request_json:
  929. collector_api.create_bacnet_point(
  930. "dev-01",
  931. {
  932. "device_id": 9,
  933. "name": "zone_temperature",
  934. "object_type": "analog-input",
  935. "object_id": 1,
  936. },
  937. )
  938. payload = request_json.call_args.kwargs["json_payload"]
  939. self.assertEqual(payload["points"][0]["object_type"], "AnalogInput")
  940. with patch(
  941. "data_collector_mcp.collector_api.request_json",
  942. return_value={"state": 0},
  943. ) as request_json:
  944. collector_api.edit_bacnet_point(
  945. "dev-01",
  946. {
  947. "id": 101,
  948. "name": "zone_temperature_edited",
  949. "object_type": "analogInput",
  950. "object_id": 1,
  951. },
  952. )
  953. payload = request_json.call_args.kwargs["json_payload"]
  954. self.assertEqual(payload["object_type"], "AnalogInput")
  955. def test_edit_bacnet_point_uses_id(self) -> None:
  956. self._patch_project()
  957. response = {"state": 0, "state_info": "成功"}
  958. with patch(
  959. "data_collector_mcp.collector_api.request_json",
  960. return_value=response,
  961. ) as request_json:
  962. result = collector_api.edit_bacnet_point(
  963. "dev-01",
  964. {
  965. "id": 101,
  966. "name": "zone_temperature_edited",
  967. "object_type": "AnalogInput",
  968. "object_id": 1,
  969. "point_id": "AI_TEMP_EDITED",
  970. "invalid_values": "-9999",
  971. },
  972. )
  973. self.assertEqual(result, response)
  974. self.assertEqual(
  975. request_json.call_args.args[:3],
  976. ("POST", "http://collector.test/api/collector/bacnet/point/edit", "token"),
  977. )
  978. payload = request_json.call_args.kwargs["json_payload"]
  979. self.assertEqual(payload["id"], 101)
  980. self.assertEqual(payload["object_name"], "zone_temperature_edited")
  981. self.assertEqual(payload["point_id"], "AI_TEMP_EDITED")
  982. self.assertEqual(payload["invalid_values"], "-9999")
  983. self.assertIsNone(payload["valid_range_start"])
  984. self.assertNotIn("ori_id", payload)
  985. def test_bacnet_payloads_validate_required_fields(self) -> None:
  986. self._patch_project()
  987. with self.assertRaisesRegex(ValueError, "payload.bacnet_device_id is required"):
  988. collector_api.create_bacnet_device("dev-01", {"name": "bacnet_1", "ip": "192.168.1.20"})
  989. with self.assertRaisesRegex(ValueError, "payload.object_name is required"):
  990. collector_api.create_bacnet_point(
  991. "dev-01",
  992. {"device_id": 1, "object_type": "AnalogInput", "object_id": 1},
  993. )
  994. with self.assertRaisesRegex(ValueError, "payload.object_id must be between"):
  995. collector_api.edit_bacnet_point(
  996. "dev-01",
  997. {"id": 1, "name": "bad", "object_type": "AnalogInput", "object_id": 4_194_304},
  998. )
  999. if __name__ == "__main__":
  1000. unittest.main()