common.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import atexit
  2. import json
  3. import os
  4. import re
  5. import socket
  6. import threading
  7. import time
  8. import urllib.error
  9. import urllib.request
  10. from contextlib import closing
  11. import uvicorn
  12. from app.main import app
  13. CONFIGURED_BASE_URL = os.getenv("DATA_COLLECTOR_BASE_URL", "").rstrip("/")
  14. REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "20"))
  15. DEVICE_IP = os.getenv("MODBUS_DEVICE_IP", "192.168.75.240")
  16. MODBUS_TCP_PORT = int(os.getenv("MODBUS_TCP_PORT", "505"))
  17. SLAVE_ID = int(os.getenv("MODBUS_SLAVE_ID", "1"))
  18. FUNC_CODE_COIL = 1
  19. FUNC_CODE_DISCRETE_INPUT = 2
  20. FUNC_CODE_HOLDING_REGISTER = 3
  21. FUNC_CODE_INPUT_REGISTER = 4
  22. REGISTER_POINT_SAMPLES = [
  23. {"address": 0, "type": "bool", "bit": 0},
  24. {"address": 1, "type": "float32"},
  25. {"address": 3, "type": "float64"},
  26. {"address": 7, "type": "int16"},
  27. {"address": 8, "type": "uint16"},
  28. {"address": 9, "type": "int32"},
  29. {"address": 11, "type": "uint32"},
  30. {"address": 13, "type": "int64"},
  31. {"address": 17, "type": "uint64"},
  32. ]
  33. FRAME_PATTERN = re.compile(r"^(Tx|Rx):\d{3}-[0-9A-F]{2}(?: [0-9A-F]{2})*$")
  34. _LOCAL_API_SERVER = None
  35. def find_free_port() -> int:
  36. with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
  37. sock.bind(("127.0.0.1", 0))
  38. return int(sock.getsockname()[1])
  39. class LocalApiServer:
  40. def __init__(self) -> None:
  41. self.port = find_free_port()
  42. self.base_url = f"http://127.0.0.1:{self.port}"
  43. config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_level="critical", access_log=False)
  44. self.server = uvicorn.Server(config)
  45. self.thread = threading.Thread(target=self.server.run, daemon=True)
  46. def start(self) -> None:
  47. self.thread.start()
  48. deadline = time.time() + 10
  49. while time.time() < deadline:
  50. try:
  51. with urllib.request.urlopen(f"{self.base_url}/api/dc-debugtool/health", timeout=1) as response:
  52. if response.status == 200:
  53. return
  54. except OSError:
  55. time.sleep(0.05)
  56. raise RuntimeError("local API test server did not start")
  57. def stop(self) -> None:
  58. self.server.should_exit = True
  59. self.thread.join(timeout=5)
  60. def base_url() -> str:
  61. global _LOCAL_API_SERVER
  62. if CONFIGURED_BASE_URL:
  63. return CONFIGURED_BASE_URL
  64. if _LOCAL_API_SERVER is None:
  65. _LOCAL_API_SERVER = LocalApiServer()
  66. _LOCAL_API_SERVER.start()
  67. return _LOCAL_API_SERVER.base_url
  68. def stop_local_api_server() -> None:
  69. if _LOCAL_API_SERVER is not None:
  70. _LOCAL_API_SERVER.stop()
  71. atexit.register(stop_local_api_server)
  72. def post_json(path: str, payload: dict) -> tuple[int, dict]:
  73. data = json.dumps(payload).encode("utf-8")
  74. request = urllib.request.Request(
  75. f"{base_url()}{path}",
  76. data=data,
  77. headers={"Content-Type": "application/json"},
  78. method="POST",
  79. )
  80. try:
  81. with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
  82. body = response.read().decode("utf-8")
  83. return response.status, json.loads(body)
  84. except urllib.error.HTTPError as exc:
  85. body = exc.read().decode("utf-8")
  86. return exc.code, json.loads(body)
  87. def raw_read_payload(function_code: int, address: int, quantity: int) -> dict:
  88. return {
  89. "device_type": "ModbusTCP",
  90. "ip": DEVICE_IP,
  91. "port": MODBUS_TCP_PORT,
  92. "word_byte_order": "ABCD",
  93. "address_base": 0,
  94. "slave_id": SLAVE_ID,
  95. "read": {
  96. "function_code": function_code,
  97. "address": address,
  98. "quantity": quantity,
  99. },
  100. }
  101. def point_read_payload(points: list[dict]) -> dict:
  102. return {
  103. "device_type": "ModbusTCP",
  104. "ip": DEVICE_IP,
  105. "port": MODBUS_TCP_PORT,
  106. "word_byte_order": "ABCD",
  107. "address_base": 0,
  108. "slave_id": SLAVE_ID,
  109. "points": points,
  110. }
  111. def all_type_point_specs() -> list[dict]:
  112. points = []
  113. for function_code in [FUNC_CODE_COIL, FUNC_CODE_DISCRETE_INPUT]:
  114. for address in [0, 1, 16, 17]:
  115. points.append({"function_code": function_code, "address": address, "type": "bool"})
  116. for function_code in [FUNC_CODE_HOLDING_REGISTER, FUNC_CODE_INPUT_REGISTER]:
  117. for sample in REGISTER_POINT_SAMPLES:
  118. point = {"function_code": function_code, "address": sample["address"], "type": sample["type"]}
  119. if "bit" in sample:
  120. point["bit"] = sample["bit"]
  121. points.append(point)
  122. return points
  123. def parse_frame(frame: str) -> tuple[str, list[int]]:
  124. direction, payload = frame.split("-", 1)
  125. return direction[:2], [int(item, 16) for item in payload.split()]
  126. def assert_response_contract(testcase, status: int, data: dict) -> None:
  127. testcase.assertEqual(200, status, data)
  128. testcase.assertIn("code", data)
  129. testcase.assertIn("msg", data)
  130. testcase.assertIn("data", data)
  131. testcase.assertIsInstance(data["data"], dict)
  132. def assert_frame_format(testcase, communication: list[str]) -> None:
  133. testcase.assertTrue(communication, "communication should not be empty")
  134. for frame in communication:
  135. testcase.assertRegex(frame, FRAME_PATTERN)