api_client.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """HTTP client for communicating with Bambuddy backend."""
  2. import asyncio
  3. import logging
  4. from collections import deque
  5. import httpx
  6. logger = logging.getLogger(__name__)
  7. MAX_BUFFER_SIZE = 100
  8. # Where a tag write's result goes; main checks the buffer for it (#3076).
  9. WRITE_RESULT_PATH = "/nfc/write-result"
  10. def _says_unregistered(response: httpx.Response) -> bool:
  11. """The backend's own answer for a device it does not know -- not a bare
  12. 404 from a reverse proxy while Bambuddy is restarting."""
  13. try:
  14. return response.json().get("detail") == "Device not registered"
  15. except Exception:
  16. return False
  17. class APIClient:
  18. def __init__(self, backend_url: str, api_key: str):
  19. self._base = backend_url.rstrip("/") + "/api/v1/spoolbuddy"
  20. self._headers = {"X-API-Key": api_key} if api_key else {}
  21. self._client = httpx.AsyncClient(timeout=10.0, headers=self._headers)
  22. self._backoff = 1.0
  23. self._max_backoff = 30.0
  24. self._buffer: deque[dict] = deque(maxlen=MAX_BUFFER_SIZE)
  25. self._connected = False
  26. # Set when a heartbeat is refused because the backend no longer knows
  27. # this device (an admin unregistered it); the heartbeat loop registers
  28. # again and clears it.
  29. self.unregistered = False
  30. async def close(self):
  31. await self._client.aclose()
  32. async def _post(self, path: str, data: dict) -> dict | None:
  33. try:
  34. resp = await self._client.post(f"{self._base}{path}", json=data)
  35. resp.raise_for_status()
  36. self._backoff = 1.0
  37. self._connected = True
  38. return resp.json()
  39. except httpx.HTTPStatusError as e:
  40. if e.response.status_code < 500:
  41. # Refused, not lost: sending it again later changes nothing,
  42. # and a buffered refusal would block every item behind it.
  43. logger.warning("POST %s refused (%s)", path, e.response.status_code)
  44. if e.response.status_code == 404 and path.endswith("/heartbeat") and _says_unregistered(e.response):
  45. self.unregistered = True
  46. return None
  47. if self._connected:
  48. logger.warning("Backend connection lost: %s", e)
  49. self._connected = False
  50. self._buffer.append({"path": path, "data": data})
  51. return None
  52. except Exception as e:
  53. if self._connected:
  54. logger.warning("Backend connection lost: %s", e)
  55. self._connected = False
  56. self._buffer.append({"path": path, "data": data})
  57. return None
  58. async def _get(self, path: str) -> dict | None:
  59. try:
  60. resp = await self._client.get(f"{self._base}{path}")
  61. resp.raise_for_status()
  62. return resp.json()
  63. except Exception as e:
  64. logger.warning("GET %s failed: %s", path, e)
  65. return None
  66. def has_buffered(self, path: str) -> bool:
  67. """Whether a POST to *path* is still waiting in the buffer to be sent."""
  68. return any(item["path"] == path for item in self._buffer)
  69. async def _flush_buffer(self):
  70. while self._buffer:
  71. item = self._buffer[0]
  72. try:
  73. resp = await self._client.post(f"{self._base}{item['path']}", json=item["data"])
  74. resp.raise_for_status()
  75. self._buffer.popleft()
  76. except httpx.HTTPStatusError as e:
  77. if e.response.status_code >= 500:
  78. break
  79. # Refused: dropped, so the items behind it still go out.
  80. self._buffer.popleft()
  81. except Exception:
  82. break
  83. async def register_device(
  84. self,
  85. device_id: str,
  86. hostname: str,
  87. ip_address: str,
  88. firmware_version: str | None = None,
  89. has_nfc: bool = True,
  90. has_scale: bool = True,
  91. tare_offset: int = 0,
  92. calibration_factor: float = 1.0,
  93. nfc_reader_type: str | None = None,
  94. nfc_connection: str | None = None,
  95. backend_url: str | None = None,
  96. has_backlight: bool = False,
  97. ) -> dict | None:
  98. while True:
  99. result = await self._post(
  100. "/devices/register",
  101. {
  102. "device_id": device_id,
  103. "hostname": hostname,
  104. "ip_address": ip_address,
  105. "firmware_version": firmware_version,
  106. "has_nfc": has_nfc,
  107. "has_scale": has_scale,
  108. "tare_offset": tare_offset,
  109. "calibration_factor": calibration_factor,
  110. "nfc_reader_type": nfc_reader_type,
  111. "nfc_connection": nfc_connection,
  112. "backend_url": backend_url,
  113. "has_backlight": has_backlight,
  114. },
  115. )
  116. if result is not None:
  117. logger.info("Registered with backend as %s", device_id)
  118. return result
  119. logger.warning("Registration failed, retrying in %.0fs...", self._backoff)
  120. await asyncio.sleep(self._backoff)
  121. self._backoff = min(self._backoff * 2, self._max_backoff)
  122. async def heartbeat(
  123. self,
  124. device_id: str,
  125. nfc_ok: bool,
  126. scale_ok: bool,
  127. uptime_s: int,
  128. ip_address: str | None = None,
  129. firmware_version: str | None = None,
  130. nfc_reader_type: str | None = None,
  131. nfc_connection: str | None = None,
  132. backend_url: str | None = None,
  133. system_stats: dict | None = None,
  134. ) -> dict | None:
  135. payload: dict = {
  136. "nfc_ok": nfc_ok,
  137. "scale_ok": scale_ok,
  138. "uptime_s": uptime_s,
  139. "ip_address": ip_address,
  140. "firmware_version": firmware_version,
  141. "nfc_reader_type": nfc_reader_type,
  142. "nfc_connection": nfc_connection,
  143. "backend_url": backend_url,
  144. }
  145. if system_stats is not None:
  146. payload["system_stats"] = system_stats
  147. result = await self._post(
  148. f"/devices/{device_id}/heartbeat",
  149. payload,
  150. )
  151. if result and self._buffer:
  152. await self._flush_buffer()
  153. return result
  154. async def tag_scanned(
  155. self,
  156. device_id: str,
  157. tag_uid: str,
  158. tray_uuid: str | None = None,
  159. sak: int | None = None,
  160. tag_type: str | None = None,
  161. raw_blocks: dict[str, str] | None = None,
  162. ) -> dict | None:
  163. return await self._post(
  164. "/nfc/tag-scanned",
  165. {
  166. "device_id": device_id,
  167. "tag_uid": tag_uid,
  168. "tray_uuid": tray_uuid,
  169. "sak": sak,
  170. "tag_type": tag_type,
  171. "raw_blocks": raw_blocks,
  172. },
  173. )
  174. async def tag_removed(self, device_id: str, tag_uid: str) -> dict | None:
  175. return await self._post(
  176. "/nfc/tag-removed",
  177. {
  178. "device_id": device_id,
  179. "tag_uid": tag_uid,
  180. },
  181. )
  182. async def update_tare(self, device_id: str, tare_offset: int) -> dict | None:
  183. return await self._post(
  184. f"/devices/{device_id}/calibration/set-tare",
  185. {"tare_offset": tare_offset},
  186. )
  187. async def scale_reading(
  188. self, device_id: str, weight_grams: float, stable: bool, raw_adc: int | None = None
  189. ) -> dict | None:
  190. return await self._post(
  191. "/scale/reading",
  192. {
  193. "device_id": device_id,
  194. "weight_grams": weight_grams,
  195. "stable": stable,
  196. "raw_adc": raw_adc,
  197. },
  198. )
  199. async def write_tag_result(
  200. self, device_id: str, spool_id: int, tag_uid: str, success: bool, message: str | None = None
  201. ) -> dict | None:
  202. return await self._post(
  203. WRITE_RESULT_PATH,
  204. {
  205. "device_id": device_id,
  206. "spool_id": spool_id,
  207. "tag_uid": tag_uid,
  208. "success": success,
  209. "message": message,
  210. },
  211. )
  212. async def diagnostic_result(
  213. self,
  214. device_id: str,
  215. diagnostic: str,
  216. success: bool,
  217. output: str,
  218. exit_code: int,
  219. ) -> dict | None:
  220. return await self._post(
  221. f"/diagnostics/{device_id}/result",
  222. {
  223. "diagnostic": diagnostic,
  224. "success": success,
  225. "output": output,
  226. "exit_code": exit_code,
  227. },
  228. )
  229. async def system_command_result(
  230. self,
  231. device_id: str,
  232. command: str,
  233. success: bool,
  234. message: str | None = None,
  235. ) -> dict | None:
  236. return await self._post(
  237. f"/devices/{device_id}/system/command-result",
  238. {
  239. "command": command,
  240. "success": success,
  241. "message": message,
  242. },
  243. )