api_client.py 7.3 KB

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