Files
CSG-Picker-Shipper-Helper/app/scale.py
T
2026-07-06 00:24:30 -05:00

170 lines
4.9 KiB
Python

"""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)