Reference for the C++ firmware in src/main.cpp.
- High-level overview
- Boot sequence
- Main loop
- Subsystems
- REST API
- Persistent storage (NVS)
- State machines
- Memory budget
The firmware is a single-file Arduino sketch (src/main.cpp, ~3000 lines) that runs on an ESP32. It's organised in clearly-marked sections, each separated by a comment banner :
// ============================================================================
// HARDWARE CONFIGURATION
// ============================================================================
// ============================================================================
// FIREBASE AUTHENTICATION
// ============================================================================
// ... etcThe sections are :
- Hardware configuration (pins, addresses, thresholds)
- Forward declarations
- Global state (variables, filters, caches)
- OLED display
- WiFi / WiFiManager / mDNS
- LittleFS bootstrapping
- Firebase authentication (sign-in, refresh, token endpoint)
- Firestore REST API (heartbeat, weight write, twin-tag pairing)
- WebSocket
- Weight filter (median + EMA + hysteresis + dead-zone)
- Auto-tare logic
- HTTP REST endpoints
- RFID readers (dual RC522)
- Continuous servo state machine
- Setup() and loop()
setup()
├── Serial.begin(115200)
├── Wire.begin(21, 22) ← I²C bus for OLED
├── display.begin() ← OLED init
├── displayMessage("Starting...", "v2.0.0")
├── prefs.begin("config")
│ ├── Load fbEmail, fbPass (legacy)
│ ├── Load fbRefresh, fbUid ← NEW: token-based auth
│ └── Load calFactor
├── setupWiFi() ← WiFiManager auto-connect (or AP fallback)
├── ensureFirebaseToken() ← Refresh token via securetoken.googleapis.com
├── initScaleFirestoreSync() ← Sets gScaleDocPath = users/{uid}/scales/{mac}
├── setupFileSystem() ← LittleFS mount
├── setupWebServer() ← AsyncWebServer + /ws WebSocket
├── setupScale() ← HX711 init, restore tare offset
├── setupRFID() ← Both RC522 PCD_Init()
└── setupServo() ← FS90R, force STOP position
The loop() function is non-blocking and runs every ~10 ms :
void loop() {
blinkLED(); // 1 Hz heartbeat indicator
detectWiFiNetworkChange(); // wipe Firebase if SSID changed
sendScaleHeartbeat(); // every 30 s
if (!rfidLockedForCurrentLoad && !autoTarePending) {
pollRFIDReaders(); // both RC522
fetchMetaIfNewUid(); // brand/material/colour
}
float weight = readWeight();
handleAutoTare(weight);
updateServoWorkflow(weight);
detectSpoolRemoval();
every(WS_UPDATE_INTERVAL_MS) {
broadcastWeightOverWebSocket();
updateOLED();
}
if (!autoTarePending) handleAutoPush(weight);
delay(10);
}The HX711 raw output is noisy : ±2 g spikes are common. We chain three filters :
- Median filter (window 15) : removes outliers
- EMA (α = 0.05 fine, 0.12 fast) : smooths drift
- Hysteresis + dead-zone : final display value only changes if the delta exceeds 0.5 g
float raw = scale.get_units(1);
gMedianBuf[gMedianIdx] = raw;
float median = computeMedian();
gEmaWeight += alpha * (median - gEmaWeight);
float displayed = applyHysteresis(applyDeadZone(gEmaWeight));The STABLE_DISPLAY_MS (1500 ms) of low-delta is required before a new value is "committed" to the display.
When weight stays in the 0.5–8 g range for 1.2 s after spool removal, the scale auto-tares. This corrects for any drift introduced during the previous weighing cycle.
The auto-tare is gated :
- It activates only if
servoLockedUntilAutotareis true (i.e. just sent a weight) - It clears
firstUidDetectedMs, RFID locks, and stops the servo
When two different tags are detected on the same spool within RFID_SECOND_TAG_TIMEOUT_MS (15 s), the firmware applies a decision matrix :
tagA.twin_tag_uid |
tagB.twin_tag_uid |
Action |
|---|---|---|
null, null |
New pair → set A.twin = B, B.twin = A |
|
B, A |
✅ Already correctly paired → just update both | |
B, null |
B.twin = A, update both |
|
null, A |
A.twin = B, update both |
|
C (≠B), * |
🚨 Conflict — A is paired with another spool C → skip write | |
*, D (≠A) |
🚨 Conflict — B is paired with another spool D → skip write | |
C, D (both ≠ each other & ≠self) |
Two different multi-spool stacks → skip write |
This logic is in updateScaleLastSpool() around line 1380-1530.
OFF (no weight)
│ weight ≥ 150 g
▼
SEARCHING ←──── (no UID detected)
│ UID detected
▼
STOPPED ←──── (during weighing + after Firestore write)
│ auto-tare done + spool removed
▼
OFF
stopServoSearch() calls spoolServo.detach() because writeMicroseconds(SERVO_STOP_US) alone doesn't reliably stop continuous servos (ESP32Servo lib quirk).
All endpoints return JSON unless noted. CORS is not enabled — the UI must be served from the same origin (the ESP32 itself).
GET /api/status
Returns the live state :
{
"weight": 250,
"rawWeight": 250.34,
"uid": "0123456789ABCDEF",
"uid_hex": "0123456789ABCDEF",
"uid2": "",
"uid2_hex": "",
"wifi": "MyHomeWiFi",
"ip": "192.168.1.42",
"mdns": "tigerscale-1F94.local",
"cloud": "ok",
"firebaseConfigured": true,
"firebaseAuth": true,
"firebaseEmail": "user@example.com",
"calibrationFactor": 406.0,
"uptime_s": 1234,
"sendToCloud": ""
}POST /api/firebase/auth # email + password sign-in
GET /api/firebase/auth # auth status
DELETE /api/firebase/auth # sign out (wipes all tokens)
POST /api/firebase/token # ⭐ NEW : direct token storage (used by OAuth bridge)
The token endpoint accepts a body of :
{
"idToken": "eyJ...",
"refreshToken": "AMf...",
"uid": "abc123",
"email": "user@example.com",
"displayName": "Jane",
"provider": "google.com"
}It stores the refresh token in NVS and never asks for a password.
POST /api/tare # force a manual tare
POST /api/calibration # body: { factor: 406.0 }
POST /api/weight # push a weight value (with optional uid override)
POST /api/push-weight # legacy push (no uid override)
POST /api/reset-wifi # forget Wi-Fi credentials, reboot
POST /api/factory-reset # wipe everything, reboot
GET /api/ping # → "pong"
WS /ws
The ESP32 broadcasts a JSON snapshot every 250 ms :
{ "weight": 250, "uid": "...", "uid2": "" }It also broadcasts firebaseStatus events when the auth state changes.
The ESP32's non-volatile storage (NVS) is used for settings that survive reboots :
| Key | Type | Purpose |
|---|---|---|
fbEmail |
string | Firebase user email (also for Google) |
fbPass |
string | Legacy password — not set for token-based auth |
fbRefresh |
string | ⭐ Firebase refresh token (Google + email/password) |
fbUid |
string | Firebase UID |
apiKey |
string | (Legacy) TigerTag API key for Cloud Function bridge |
calFactor |
float | HX711 scale calibration factor |
tareFactor |
float | HX711 tare offset |
NVS is partitioned at offset 0x9000 (size 20 KB). Data persists across firmware updates as long as the partition layout doesn't change.
A factory reset (POST /api/factory-reset) clears the entire config namespace.
| Resource | Used | Total | % |
|---|---|---|---|
| Flash | ~1.3 MB | 1.5 MB (app0) | 82 % |
| RAM (static) | ~50 KB | 320 KB | 16 % |
| RAM (heap, runtime) | ~80-120 KB | ~270 KB | varies |
| LittleFS | ~900 KB | 896 KB | 100 % |
There's headroom for ~270 KB more firmware code (e.g. for OTA support in v2.1).
Recommended workflow :
- Open an issue describing the feature
- Fork → branch
feature/xxx - Add code in the appropriate section (don't dump everything in
loop()) - Update CHANGELOG.md
- Test with
pio runand on hardware - Open PR — see CONTRIBUTING.md
Common pitfalls :
- Don't use
delay()> 50 ms inloop()— it kills the WebSocket / RFID polling responsiveness - Don't use
String + Stringin tight loops — fragmentation kills the ESP32 long-term - Use
[TAG]prefixes inSerial.printffor log filtering :[BOOT],[FIREBASE],[WEIGHT], etc.