소스 검색

Fix SpoolBuddy scale tare & calibration not being applied

  The tare and calibrate buttons on the Settings page queued commands
  but never executed them due to five broken links:

  1. Daemon received tare command but never called scale.tare()
  2. No endpoint to persist tare offset back to backend DB
  3. Heartbeat never called scale.update_calibration()
  4. Heartbeat response with stale values overwrote new tare to zero
  5. set-factor used DB tare_offset (stale/zero), producing wrong
     calibration factor — empty scale showed ~5000g

  Fixed daemon to execute tare, persist result, and propagate
  calibration. Calibration step now captures raw ADC at tare time
  and sends it with step 2, making factor computation self-contained.

  Replaced calibration weight input with compact touch numpad for
  the RPi kiosk's 1024x600 touchscreen (no physical keyboard).
maziggy 6 달 전
부모
커밋
04e64ffde1

+ 1 - 1
CHANGELOG.md

@@ -17,7 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
 - **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
 - **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
-- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Three bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero. Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration weight input now uses a touch-friendly numpad instead of a native `<input type="number">`, since the RPi kiosk has no physical keyboard.
+- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.
 - **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
 
 ### Improved

+ 5 - 2
backend/app/api/routes/spoolbuddy.py

@@ -342,11 +342,14 @@ async def set_calibration_factor(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    raw_delta = req.raw_adc - device.tare_offset
+    tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
+    raw_delta = req.raw_adc - tare
     if raw_delta == 0:
         raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
 
     device.calibration_factor = req.known_weight_grams / raw_delta
+    if req.tare_raw_adc is not None:
+        device.tare_offset = tare
     await db.commit()
 
     logger.info(
@@ -355,7 +358,7 @@ async def set_calibration_factor(
         device.calibration_factor,
         req.known_weight_grams,
         req.raw_adc,
-        device.tare_offset,
+        tare,
     )
     return CalibrationResponse(
         tare_offset=device.tare_offset,

+ 1 - 0
backend/app/schemas/spoolbuddy.py

@@ -99,6 +99,7 @@ class SetTareRequest(BaseModel):
 class SetCalibrationFactorRequest(BaseModel):
     known_weight_grams: float = Field(..., gt=0)
     raw_adc: int
+    tare_raw_adc: int | None = None
 
 
 class CalibrationResponse(BaseModel):

+ 2 - 2
frontend/src/api/client.ts

@@ -4849,10 +4849,10 @@ export const spoolbuddyApi = {
   getCalibration: (deviceId: string) =>
     request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
 
-  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number) =>
+  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number, tareRawAdc?: number) =>
     request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {
       method: 'POST',
-      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc }),
+      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc, tare_raw_adc: tareRawAdc }),
     }),
 
   updateSpoolWeight: (spoolId: number, weightGrams: number) =>

+ 13 - 8
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -23,6 +23,7 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
   const [calibrating, setCalibrating] = useState(false);
   const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
   const [knownWeight, setKnownWeight] = useState('500');
+  const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);
   const [taring, setTaring] = useState(false);
 
   const numpadPress = (key: string) => {
@@ -54,6 +55,8 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
     if (calStep === 'tare') {
       setCalibrating(true);
       try {
+        // Capture raw ADC before taring — this is our zero reference
+        setTareRawAdc(rawAdc);
         await spoolbuddyApi.tare(device.device_id);
         setCalStep('weight');
       } catch (e) {
@@ -66,7 +69,7 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
       if (rawAdc === null || !weightNum || weightNum <= 0) return;
       setCalibrating(true);
       try {
-        await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc);
+        await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);
         setCalStep('idle');
       } catch (e) {
         console.error('Failed to calibrate:', e);
@@ -123,7 +126,7 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
           </button>
         </div>
       ) : (
-        <div className="border border-zinc-700 rounded-lg p-3 space-y-3">
+        <div className="border border-zinc-700 rounded-lg p-3 space-y-2">
           <div className="text-sm font-medium text-zinc-200">
             {calStep === 'tare'
               ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from the scale')
@@ -131,17 +134,19 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
           </div>
 
           {calStep === 'weight' && (
-            <div className="space-y-2">
-              <label className="text-xs text-zinc-400">{t('spoolbuddy.settings.knownWeight', 'Known weight (g)')}</label>
-              <div className="bg-zinc-900 border border-zinc-600 rounded px-3 py-2 text-right text-lg font-mono text-zinc-100 min-h-[44px]">
-                {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
+            <div className="space-y-1.5">
+              <div className="flex items-center gap-2">
+                <span className="text-xs text-zinc-400">{t('spoolbuddy.settings.knownWeight', 'Known weight (g)')}</span>
+                <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-base font-mono text-zinc-100">
+                  {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
+                </div>
               </div>
-              <div className="grid grid-cols-4 gap-1.5">
+              <div className="grid grid-cols-4 gap-1">
                 {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
                   <button
                     key={key}
                     onClick={() => numpadPress(key)}
-                    className={`py-3 rounded-lg text-base font-medium transition-colors min-h-[48px] ${
+                    className={`py-2 rounded text-sm font-medium transition-colors min-h-[36px] ${
                       key === 'backspace'
                         ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
                         : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-BW78djlt.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-BnyNVaG5.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-D_O4r8aB.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C-1HSQvy.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BnyNVaG5.css">
+    <script type="module" crossorigin src="/assets/index-D_O4r8aB.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BW78djlt.css">
   </head>
   <body>
     <div id="root"></div>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.