"""USB HID scale reader. Supports any scale that implements the USB HID Weighing Device specification (Usage Page 0x8D). This covers most common shipping scales including: - Dymo / Stamps.com USB scales (VID 0x0922) - Mettler Toledo postal scales - Generic USB postal scales Gracefully no-ops when the `hid` package is not installed or no scale is found, so the rest of the app runs normally on machines without a scale connected. """ from __future__ import annotations import logging import threading import time from typing import Callable log = logging.getLogger(__name__) try: import hid as _hid _HID_AVAILABLE = True except ImportError: _hid = None # type: ignore[assignment] _HID_AVAILABLE = False # USB HID Usage Page for Weighing Devices _WEIGHING_USAGE_PAGE = 0x8D # HID weight unit codes _UNIT_GRAM = 2 _UNIT_OUNCE = 11 # avoirdupois oz — most US postal scales report this _UNIT_POUND = 12 # HID status codes _STATUS_FAULT = 1 _STATUS_STABLE = 2 _STATUS_IN_MOTION = 3 _STATUS_OVER = 4 _STATUS_UNDER_ZERO = 5 _STATUS_CALIBRATE = 6 _STATUS_TARE = 7 def is_available() -> bool: """True if the hid package is importable.""" return _HID_AVAILABLE def find_scale() -> tuple[int, int] | None: """Return (vendor_id, product_id) of the first HID weighing device found, or None.""" if not _HID_AVAILABLE: return None try: for dev in _hid.enumerate(): if dev.get("usage_page") == _WEIGHING_USAGE_PAGE: log.info( "Scale found: %s (VID=%04x PID=%04x)", dev.get("product_string", "Unknown"), dev["vendor_id"], dev["product_id"], ) return dev["vendor_id"], dev["product_id"] except Exception as exc: log.warning("Scale enumeration error: %s", exc) return None def read_weight_oz(vendor_id: int, product_id: int) -> float | None: """Open the scale, read one report, return weight in ounces (or None on failure). Returns None when the scale is in motion, over capacity, or not responding. """ if not _HID_AVAILABLE: return None try: with _hid.Device(vendor_id, product_id) as dev: data = dev.read(64, timeout=2000) except Exception as exc: log.debug("Scale read error: %s", exc) return None if not data or len(data) < 6: return None # Standard HID scale report: # byte 0: report ID # byte 1: status # byte 2: weight unit # byte 3: exponent (signed) # byte 4: weight LSB # byte 5: weight MSB status = data[1] unit = data[2] exponent = data[3] if data[3] < 128 else data[3] - 256 # treat as signed raw = (data[5] << 8) | data[4] weight = raw * (10.0 ** exponent) if status not in (_STATUS_STABLE, _STATUS_IN_MOTION): return None if weight < 0: return None if unit == _UNIT_OUNCE: return weight if unit == _UNIT_GRAM: return round(weight * 0.035274, 2) if unit == _UNIT_POUND: return round(weight * 16, 2) log.debug("Unknown scale unit code: %d", unit) return None class ScalePoller: """Background thread that polls the scale and fires a callback with weight in oz. Usage: poller = ScalePoller(on_weight=my_callback) poller.start() # begin polling poller.stop() # stop polling """ POLL_INTERVAL = 0.4 # seconds between reads def __init__(self, on_weight: Callable[[float], None]) -> None: self._on_weight = on_weight self._running = False self._thread: threading.Thread | None = None self._device: tuple[int, int] | None = None def start(self) -> bool: """Start polling. Returns False if no scale is found.""" if self._running: return True self._device = find_scale() if self._device is None: log.warning("No USB scale found — auto-weigh unavailable.") return False self._running = True self._thread = threading.Thread(target=self._run, daemon=True, name="ScalePoller") self._thread.start() return True def stop(self) -> None: self._running = False def read_once(self) -> float | None: """Single blocking read — used by the Weigh button.""" dev = self._device or find_scale() if dev is None: return None self._device = dev return read_weight_oz(*dev) def _run(self) -> None: last: float | None = None while self._running: if self._device: oz = read_weight_oz(*self._device) if oz is not None and oz != last: last = oz try: self._on_weight(oz) except Exception: pass time.sleep(self.POLL_INTERVAL)