1212// §3 FORWARD DECLARATIONS 147– 226
1313// §4 WEIGHT ROUNDING 227– 246
1414// §5 GLOBAL OBJECTS 247– 260
15- // §6 CONFIGURATION VARIABLES 261– 449
16- // §7 OLED DISPLAY 450 – 567
17- // §8 CLOUD PARSING 568 – 582
18- // §9 WIFI SETUP 583 – 855
19- // §10 LITTLEFS 856 – 900
20- // §11 FIREBASE AUTHENTICATION 901– 1094
21- // §12 FIRESTORE SCALE HEARTBEAT & SYNC 1095– 2059
22- // §13 WEBSOCKET 2060– 2086
23- // §14 5 — CLOUD WORKER TASK (non-blocking Firestore on core 0) 2087– 2121
24- // §15 5 — UNIFIED WS FRAME BUILDER 2122– 2161
25- // §16 WEIGHT FILTER HELPERS 2162– 2176
26- // §17 POST-SEND STATE RESET (shared by all send paths) 2177– 2195
27- // §18 SHARED WEIGHT PUSH HANDLER (used by /api/weight and /api/push-weight) 2196– 2268
28- // §19 WEB SERVER 2269– 2969
29- // §20 CLOUD COMMUNICATION 2970– 3152
30- // §21 WEIGH WORKFLOW (IDLE → SCANNING → STABLE_WAIT → SENDING) 3153– 3374
31- // §22 mDNS 3375– 3412
32- // §23 SCALE 3413– 3504
33- // §24 RFID 3505– 3834
34- // §25 OTA — Over-the-air firmware + filesystem update 3835– 4203
35- // §26 SETUP & LOOP 4204– 4576
15+ // §6 CONFIGURATION VARIABLES 261– 454
16+ // §7 OLED DISPLAY 455 – 572
17+ // §8 CLOUD PARSING 573 – 587
18+ // §9 WIFI SETUP 588 – 860
19+ // §10 LITTLEFS 861 – 905
20+ // §11 FIREBASE AUTHENTICATION 906– 1099
21+ // §12 FIRESTORE SCALE HEARTBEAT & SYNC 1100– 2030
22+ // §13 WEBSOCKET 2031– 2057
23+ // §14 5 — CLOUD WORKER TASK (non-blocking Firestore on core 0) 2058– 2098
24+ // §15 5 — UNIFIED WS FRAME BUILDER 2099– 2138
25+ // §16 WEIGHT FILTER HELPERS 2139– 2153
26+ // §17 POST-SEND STATE RESET (shared by all send paths) 2154– 2172
27+ // §18 SHARED WEIGHT PUSH HANDLER (used by /api/weight and /api/push-weight) 2173– 2245
28+ // §19 WEB SERVER 2246– 2946
29+ // §20 CLOUD COMMUNICATION 2947– 3129
30+ // §21 WEIGH WORKFLOW (IDLE → SCANNING → STABLE_WAIT → SENDING) 3130– 3364
31+ // §22 mDNS 3365– 3402
32+ // §23 SCALE 3403– 3494
33+ // §24 RFID 3495– 3824
34+ // §25 OTA — Over-the-air firmware + filesystem update 3825– 4193
35+ // §26 SETUP & LOOP 4194– 4566
3636//
3737// To regenerate this block: ./scripts/update_toc.sh
3838// ─── TOC END ───────────────────────────────────────────────
@@ -427,6 +427,11 @@ static String gContainerFetchUID = "";
427427static String gTwinFetchUID = " " ;
428428static String gTwinFetchResult = " " ;
429429static volatile bool gTwinFetchDone = false ;
430+ // uid_b (2nd physical tag) container prefetch — used when motor scans both tags
431+ // and uid_a happens to be the filament tag (no container_weight).
432+ static String gContainerBFetchUID = " " ;
433+ static float gContainerBFetchResult = 0 .0f ;
434+ static volatile bool gContainerBFetchDone = false ;
430435
431436static volatile bool gCloudSendPending = false ;
432437static volatile bool gCloudSendDone = false ;
@@ -1593,9 +1598,15 @@ float computeWeightAvailable(float raw_grams, const String& uid_a) {
15931598 float measure_gr = 0 .0f ;
15941599 float container;
15951600 if (wfContainerFetched && wfContainerWeight > 0 .0f ) {
1596- // Use the value prefetched during SCANNING — avoids a duplicate HTTP call
1601+ // Use the value prefetched during SCANNING (uid_a) — avoids a duplicate HTTP call
15971602 container = wfContainerWeight;
15981603 netLog (" CONTAINER cache hit=" + String (container,1 ) + " g uid=" + uid_a);
1604+ } else if (wfContainerFetched && wfContainerWeight == 0 .0f
1605+ && gContainerBFetchDone && gContainerBFetchResult > 0 .0f ) {
1606+ // uid_a has no container_weight — fall back to uid_b's prefetched container.
1607+ // Happens when motor scans both tags and uid_a is the filament tag.
1608+ container = gContainerBFetchResult ;
1609+ netLog (" CONTAINER_B fallback=" + String (container,1 ) + " g uid_b=" + gContainerBFetchUID );
15991610 } else {
16001611 container = readInventoryContainerWeight (uid_a, &measure_gr);
16011612 }
@@ -1937,55 +1948,20 @@ bool updateScaleLastSpool(const String& uid_a, const String& uid_b = "", float w
19371948 }
19381949 }
19391950 } else {
1940- // Case 2: Two different tags detected — implement decision matrix from §6.1
1941- String twin_a = " " , twin_b = " " ;
1942- readInventoryDocTwinTag (uidAHex, twin_a);
1943- readInventoryDocTwinTag (uidBHex, twin_b);
1944-
1945- Serial.printf (" [WEIGHT] 2 tags detected: uid_a=%s (twin=%s) uid_b=%s (twin=%s)\n " ,
1946- uidAHex.c_str (), twin_a.length () > 0 ? twin_a.c_str () : " none" ,
1947- uidBHex.c_str (), twin_b.length () > 0 ? twin_b.c_str () : " none" );
1948-
1949- // Decision matrix (§6.1):
1950- // [A=B, B=A] OK Paired correctly -> update both
1951- // [A=B, B=null] WARNING Asymmetric -> update both, fix B.twin_tag_uid=A
1952- // [A=null, B=A] WARNING Asymmetric -> update both, fix A.twin_tag_uid=B
1953- // [A=null, B=null] NEW New pair -> update both, set A.twin_tag_uid=B, B.twin_tag_uid=A
1954- // [A=C, B=*] CONFLICT Conflict -> only update A, log warning
1955- // [A=*, B=C] CONFLICT Conflict -> only update B, log warning
1956- // [A!=B AND A not in {B,null} AND B not in {A,null}] -> different spools -> skip
1957-
1958- bool twin_a_is_b = (twin_a == uidBHex);
1959- bool twin_b_is_a = (twin_b == uidAHex);
1960- bool twin_a_is_other = (twin_a.length () > 0 && twin_a != uidBHex);
1961- bool twin_b_is_other = (twin_b.length () > 0 && twin_b != uidAHex);
1962-
1963- if (twin_a_is_other) {
1964- // A is already paired to someone else (conflict)
1965- Serial.printf (" [WEIGHT] CONFLICT: A already paired to %s, skipping weight write\n " , twin_a.c_str ());
1966- return false ;
1967- }
1968- if (twin_b_is_other) {
1969- // B is already paired to someone else (conflict)
1970- Serial.printf (" [WEIGHT] CONFLICT: B already paired to %s, skipping weight write\n " , twin_b.c_str ());
1971- return false ;
1972- }
1973- if (!twin_a_is_b && !twin_b_is_a && twin_a.length () > 0 && twin_b.length () > 0 ) {
1974- // Both have different twin_tag_uids (different spools on platform)
1975- Serial.printf (" [WEIGHT] multi-spool: uid_a links to %s, uid_b links to %s, skipping\n " , twin_a.c_str (), twin_b.c_str ());
1976- return false ;
1977- }
1978-
1979- // All other cases are valid twin-pair scenarios — update with repair
1980- Serial.printf (" [WEIGHT] twin-pair: updating uid_a=%s uid_b=%s (non-atomic)\n " , uidAHex.c_str (), uidBHex.c_str ());
1951+ // Case 2: Two different tags physically detected simultaneously.
1952+ // Both are on the scale together — they ARE a valid pair by definition.
1953+ // No conflict check, no sync twin fetches needed.
1954+ // Always update both docs and auto-link them as twins (idempotent repair).
1955+ Serial.printf (" [WEIGHT] 2-tag pair: uid_a=%s uid_b=%s weight=%.1f\n " ,
1956+ uidAHex.c_str (), uidBHex.c_str (), weight_available);
1957+ netLog (" 2UID uid_a=" + uidAHex + " uid_b=" + uidBHex + " w=" + String (weight_available,1 ) + " g" );
19811958
19821959 HTTPClient http;
19831960 bool success = true ;
19841961
1985- // Update A ( weight + possibly twin_tag_uid repair)
1962+ // Update A — weight + twin_tag_uid link (always written so mis-linked pairs self- repair)
19861963 String docPath_a = " users/" + firebaseUid + " /inventory/" + uidAHex;
1987- String maskStr_a = " ?updateMask.fieldPaths=weight_available&updateMask.fieldPaths=last_update" ;
1988- if (!twin_a_is_b) maskStr_a += " &updateMask.fieldPaths=twin_tag_uid" ;
1964+ String maskStr_a = " ?updateMask.fieldPaths=weight_available&updateMask.fieldPaths=last_update&updateMask.fieldPaths=twin_tag_uid" ;
19891965 String url_a = " https://firestore.googleapis.com/v1/projects/tigertag-connect/databases/(default)/documents/" + docPath_a + maskStr_a;
19901966
19911967 if (http.begin (url_a)) {
@@ -1995,9 +1971,7 @@ bool updateScaleLastSpool(const String& uid_a, const String& uid_b = "", float w
19951971 StaticJsonDocument<256 > doc_a;
19961972 doc_a[" fields" ][" weight_available" ][" integerValue" ] = String ((int )roundf (weight_available));
19971973 doc_a[" fields" ][" last_update" ][" timestampValue" ] = tsStr;
1998- if (!twin_a_is_b) {
1999- doc_a[" fields" ][" twin_tag_uid" ][" stringValue" ] = uidBHex;
2000- }
1974+ doc_a[" fields" ][" twin_tag_uid" ][" stringValue" ] = uidBHex;
20011975
20021976 String payload_a;
20031977 serializeJson (doc_a, payload_a);
@@ -2013,10 +1987,9 @@ bool updateScaleLastSpool(const String& uid_a, const String& uid_b = "", float w
20131987 }
20141988 }
20151989
2016- // Update B ( weight + possibly twin_tag_uid repair )
1990+ // Update B — weight + twin_tag_uid link (always written )
20171991 String docPath_b = " users/" + firebaseUid + " /inventory/" + uidBHex;
2018- String maskStr_b = " ?updateMask.fieldPaths=weight_available&updateMask.fieldPaths=last_update" ;
2019- if (!twin_b_is_a) maskStr_b += " &updateMask.fieldPaths=twin_tag_uid" ;
1992+ String maskStr_b = " ?updateMask.fieldPaths=weight_available&updateMask.fieldPaths=last_update&updateMask.fieldPaths=twin_tag_uid" ;
20201993 String url_b = " https://firestore.googleapis.com/v1/projects/tigertag-connect/databases/(default)/documents/" + docPath_b + maskStr_b;
20211994
20221995 if (http.begin (url_b)) {
@@ -2026,9 +1999,7 @@ bool updateScaleLastSpool(const String& uid_a, const String& uid_b = "", float w
20261999 StaticJsonDocument<256 > doc_b;
20272000 doc_b[" fields" ][" weight_available" ][" integerValue" ] = String ((int )roundf (weight_available));
20282001 doc_b[" fields" ][" last_update" ][" timestampValue" ] = tsStr;
2029- if (!twin_b_is_a) {
2030- doc_b[" fields" ][" twin_tag_uid" ][" stringValue" ] = uidAHex;
2031- }
2002+ doc_b[" fields" ][" twin_tag_uid" ][" stringValue" ] = uidAHex;
20322003
20332004 String payload_b;
20342005 serializeJson (doc_b, payload_b);
@@ -2096,15 +2067,21 @@ static void cloudWorkerTask(void* /*param*/) {
20962067 for (;;) {
20972068 // ── Container fetch ──────────────────────────────────────────────────
20982069 if (gContainerFetchPending ) {
2099- String uid = gContainerFetchUID ; // local copy (thread-safe read)
2100- float cw = readInventoryContainerWeight (uid);
2101- gContainerFetchResult = cw;
2102- // Prefetch twin tag for the same UID right now — same HTTP round-trip budget,
2103- // so pushWeightToCloud won't need a separate TWIN_GET during SENDING.
2104- gTwinFetchUID = uid;
2070+ String uid_a = gContainerFetchUID ; // local copy (thread-safe read)
2071+ float cw_a = readInventoryContainerWeight (uid_a);
2072+ gContainerFetchResult = cw_a;
2073+ // Prefetch twin tag for uid_a — avoids a TWIN_GET during SENDING (Case 1).
2074+ gTwinFetchUID = uid_a;
21052075 gTwinFetchResult = " " ;
2106- readInventoryDocTwinTag (uid, gTwinFetchResult );
2107- gTwinFetchDone = true ;
2076+ readInventoryDocTwinTag (uid_a, gTwinFetchResult );
2077+ gTwinFetchDone = true ;
2078+ // If a 2nd physical tag was already detected, also prefetch its container.
2079+ // This covers Case 2 where uid_a may be the filament tag (no container_weight).
2080+ if (gContainerBFetchUID .length () > 0 ) {
2081+ gContainerBFetchResult = readInventoryContainerWeight (gContainerBFetchUID );
2082+ gContainerBFetchDone = true ;
2083+ netLog (" CONTAINER_B prefetch=" + String (gContainerBFetchResult ,1 ) + " g uid=" + gContainerBFetchUID );
2084+ }
21082085 gContainerFetchPending = false ; // clear before Done for coherent state
21092086 gContainerFetchDone = true ;
21102087 }
@@ -3209,6 +3186,7 @@ void handleWeighWorkflow(float w) {
32093186 sendCountdown = (int )((SPOOL_SCAN_DURATION_MS + 999 ) / 1000 );
32103187 // Reset async worker flags for fresh session
32113188 gContainerFetchPending = false ; gContainerFetchDone = false ;
3189+ gContainerBFetchUID = " " ; gContainerBFetchResult = 0 .0f ; gContainerBFetchDone = false ;
32123190 gCloudSendPending = false ; gCloudSendDone = false ;
32133191 netLog (" WF IDLE→SCANNING slope=" + String (wfCurrentSlope,1 ));
32143192 }
@@ -3227,6 +3205,7 @@ void handleWeighWorkflow(float w) {
32273205 resetSlopeBuffer ();
32283206 // Abandon any in-flight async requests
32293207 gContainerFetchPending = false ; gContainerFetchDone = false ;
3208+ gContainerBFetchUID = " " ; gContainerBFetchResult = 0 .0f ; gContainerBFetchDone = false ;
32303209 gCloudSendPending = false ; gCloudSendDone = false ;
32313210 netLog (" WF →IDLE (retrait: slope=" + String (wfCurrentSlope,1 ) + " w=" + String (w,1 ) + " )" );
32323211 return ;
@@ -3238,12 +3217,22 @@ void handleWeighWorkflow(float w) {
32383217 uint32_t scanDuration = (servoEnabled && hwMotorConnected)
32393218 ? SPOOL_SCAN_DURATION_MS : NO_MOTOR_UID_WAIT_MS ;
32403219
3241- // Trigger async container fetch as soon as we have the first UID
3220+ // Trigger async container fetch as soon as we have the first UID.
3221+ // Also record uid_b at that moment — cloudWorkerTask will prefetch its
3222+ // container too so Case 2 can use the right tare weight.
32423223 if (!wfContainerFetched && !gContainerFetchPending && !gContainerFetchDone
32433224 && lastUID.length () > 0 ) {
32443225 gContainerFetchUID = lastUID;
3226+ gContainerBFetchUID = lastUID2; // may be empty — that's fine
3227+ gContainerBFetchDone = false ;
3228+ gContainerBFetchResult = 0 .0f ;
32453229 gContainerFetchPending = true ; // hand off to cloudWorkerTask
32463230 }
3231+ // If uid_b arrives after the prefetch was already kicked off, update the slot
3232+ // so the worker can still fetch it (only if it hasn't started the B fetch yet).
3233+ if (gContainerFetchPending && gContainerBFetchUID .length () == 0 && lastUID2.length () > 0 ) {
3234+ gContainerBFetchUID = lastUID2;
3235+ }
32473236 // Pick up result when the worker task is done (non-blocking)
32483237 if (gContainerFetchDone ) {
32493238 wfContainerWeight = gContainerFetchResult ;
@@ -3364,6 +3353,7 @@ void handleWeighWorkflow(float w) {
33643353 rfidLockedForCurrentLoad = false ;
33653354 resetSlopeBuffer ();
33663355 gContainerFetchPending = false ; gContainerFetchDone = false ;
3356+ gContainerBFetchUID = " " ; gContainerBFetchResult = 0 .0f ; gContainerBFetchDone = false ;
33673357 gCloudSendPending = false ; gCloudSendDone = false ;
33683358 sendPhase = " " ; sendCountdown = -1 ;
33693359 netLog (" WF DONE→IDLE (spool retiré, prêt)" );
0 commit comments