api_client.py 8.6 KB

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