mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
The daemon now collects CPU temp, core count, load average, memory/disk usage, OS info, and system uptime every heartbeat using stdlib-only reads from /proc and /sys. Stats are sent as a JSON blob in the heartbeat payload, stored in a new system_stats TEXT column, and displayed in a new "System" tab in SpoolBuddy Settings with color-coded usage bars.
232 lines
7.3 KiB
Python
232 lines
7.3 KiB
Python
"""HTTP client for communicating with Bambuddy backend."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections import deque
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_BUFFER_SIZE = 100
|
|
|
|
|
|
class APIClient:
|
|
def __init__(self, backend_url: str, api_key: str):
|
|
self._base = backend_url.rstrip("/") + "/api/v1/spoolbuddy"
|
|
self._headers = {"X-API-Key": api_key} if api_key else {}
|
|
self._client = httpx.AsyncClient(timeout=10.0, headers=self._headers)
|
|
self._backoff = 1.0
|
|
self._max_backoff = 30.0
|
|
self._buffer: deque[dict] = deque(maxlen=MAX_BUFFER_SIZE)
|
|
self._connected = False
|
|
|
|
async def close(self):
|
|
await self._client.aclose()
|
|
|
|
async def _post(self, path: str, data: dict) -> dict | None:
|
|
try:
|
|
resp = await self._client.post(f"{self._base}{path}", json=data)
|
|
resp.raise_for_status()
|
|
self._backoff = 1.0
|
|
self._connected = True
|
|
return resp.json()
|
|
except Exception as e:
|
|
if self._connected:
|
|
logger.warning("Backend connection lost: %s", e)
|
|
self._connected = False
|
|
self._buffer.append({"path": path, "data": data})
|
|
return None
|
|
|
|
async def _get(self, path: str) -> dict | None:
|
|
try:
|
|
resp = await self._client.get(f"{self._base}{path}")
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.warning("GET %s failed: %s", path, e)
|
|
return None
|
|
|
|
async def _flush_buffer(self):
|
|
while self._buffer:
|
|
item = self._buffer[0]
|
|
try:
|
|
resp = await self._client.post(f"{self._base}{item['path']}", json=item["data"])
|
|
resp.raise_for_status()
|
|
self._buffer.popleft()
|
|
except Exception:
|
|
break
|
|
|
|
async def register_device(
|
|
self,
|
|
device_id: str,
|
|
hostname: str,
|
|
ip_address: str,
|
|
firmware_version: str | None = None,
|
|
has_nfc: bool = True,
|
|
has_scale: bool = True,
|
|
tare_offset: int = 0,
|
|
calibration_factor: float = 1.0,
|
|
nfc_reader_type: str | None = None,
|
|
nfc_connection: str | None = None,
|
|
backend_url: str | None = None,
|
|
has_backlight: bool = False,
|
|
) -> dict | None:
|
|
while True:
|
|
result = await self._post(
|
|
"/devices/register",
|
|
{
|
|
"device_id": device_id,
|
|
"hostname": hostname,
|
|
"ip_address": ip_address,
|
|
"firmware_version": firmware_version,
|
|
"has_nfc": has_nfc,
|
|
"has_scale": has_scale,
|
|
"tare_offset": tare_offset,
|
|
"calibration_factor": calibration_factor,
|
|
"nfc_reader_type": nfc_reader_type,
|
|
"nfc_connection": nfc_connection,
|
|
"backend_url": backend_url,
|
|
"has_backlight": has_backlight,
|
|
},
|
|
)
|
|
if result is not None:
|
|
logger.info("Registered with backend as %s", device_id)
|
|
return result
|
|
logger.warning("Registration failed, retrying in %.0fs...", self._backoff)
|
|
await asyncio.sleep(self._backoff)
|
|
self._backoff = min(self._backoff * 2, self._max_backoff)
|
|
|
|
async def heartbeat(
|
|
self,
|
|
device_id: str,
|
|
nfc_ok: bool,
|
|
scale_ok: bool,
|
|
uptime_s: int,
|
|
ip_address: str | None = None,
|
|
firmware_version: str | None = None,
|
|
nfc_reader_type: str | None = None,
|
|
nfc_connection: str | None = None,
|
|
backend_url: str | None = None,
|
|
system_stats: dict | None = None,
|
|
) -> dict | None:
|
|
payload: dict = {
|
|
"nfc_ok": nfc_ok,
|
|
"scale_ok": scale_ok,
|
|
"uptime_s": uptime_s,
|
|
"ip_address": ip_address,
|
|
"firmware_version": firmware_version,
|
|
"nfc_reader_type": nfc_reader_type,
|
|
"nfc_connection": nfc_connection,
|
|
"backend_url": backend_url,
|
|
}
|
|
if system_stats is not None:
|
|
payload["system_stats"] = system_stats
|
|
result = await self._post(
|
|
f"/devices/{device_id}/heartbeat",
|
|
payload,
|
|
)
|
|
if result and self._buffer:
|
|
await self._flush_buffer()
|
|
return result
|
|
|
|
async def tag_scanned(
|
|
self,
|
|
device_id: str,
|
|
tag_uid: str,
|
|
tray_uuid: str | None = None,
|
|
sak: int | None = None,
|
|
tag_type: str | None = None,
|
|
) -> dict | None:
|
|
return await self._post(
|
|
"/nfc/tag-scanned",
|
|
{
|
|
"device_id": device_id,
|
|
"tag_uid": tag_uid,
|
|
"tray_uuid": tray_uuid,
|
|
"sak": sak,
|
|
"tag_type": tag_type,
|
|
},
|
|
)
|
|
|
|
async def tag_removed(self, device_id: str, tag_uid: str) -> dict | None:
|
|
return await self._post(
|
|
"/nfc/tag-removed",
|
|
{
|
|
"device_id": device_id,
|
|
"tag_uid": tag_uid,
|
|
},
|
|
)
|
|
|
|
async def update_tare(self, device_id: str, tare_offset: int) -> dict | None:
|
|
return await self._post(
|
|
f"/devices/{device_id}/calibration/set-tare",
|
|
{"tare_offset": tare_offset},
|
|
)
|
|
|
|
async def scale_reading(
|
|
self, device_id: str, weight_grams: float, stable: bool, raw_adc: int | None = None
|
|
) -> dict | None:
|
|
return await self._post(
|
|
"/scale/reading",
|
|
{
|
|
"device_id": device_id,
|
|
"weight_grams": weight_grams,
|
|
"stable": stable,
|
|
"raw_adc": raw_adc,
|
|
},
|
|
)
|
|
|
|
async def write_tag_result(
|
|
self, device_id: str, spool_id: int, tag_uid: str, success: bool, message: str | None = None
|
|
) -> dict | None:
|
|
return await self._post(
|
|
"/nfc/write-result",
|
|
{
|
|
"device_id": device_id,
|
|
"spool_id": spool_id,
|
|
"tag_uid": tag_uid,
|
|
"success": success,
|
|
"message": message,
|
|
},
|
|
)
|
|
|
|
async def report_update_status(self, device_id: str, status: str, message: str = "") -> dict | None:
|
|
return await self._post(
|
|
f"/devices/{device_id}/update-status",
|
|
{"status": status, "message": message},
|
|
)
|
|
|
|
async def diagnostic_result(
|
|
self,
|
|
device_id: str,
|
|
diagnostic: str,
|
|
success: bool,
|
|
output: str,
|
|
exit_code: int,
|
|
) -> dict | None:
|
|
return await self._post(
|
|
f"/diagnostics/{device_id}/result",
|
|
{
|
|
"diagnostic": diagnostic,
|
|
"success": success,
|
|
"output": output,
|
|
"exit_code": exit_code,
|
|
},
|
|
)
|
|
|
|
async def system_command_result(
|
|
self,
|
|
device_id: str,
|
|
command: str,
|
|
success: bool,
|
|
message: str | None = None,
|
|
) -> dict | None:
|
|
return await self._post(
|
|
f"/devices/{device_id}/system/command-result",
|
|
{
|
|
"command": command,
|
|
"success": success,
|
|
"message": message,
|
|
},
|
|
)
|