Scale Sense — ESP32 Firmware¶
Overview¶
Firmware is PlatformIO (Arduino framework) in C++.
Entry: firmware/src/main.cpp
Operating modes¶
NORMAL mode
WiFi: modem sleep (power_save_mode: LIGHT)
BLE: advertising beacon only (low power)
HX711: read every 60s
MQTT: publish reading every 60s
Display: beer name + level bar + battery %
MAINTENANCE mode (entered via BLE connect or MQTT command)
WiFi: full power
BLE: connected, streaming weight at 5Hz (200ms interval)
HX711: read at 5Hz during BLE stream
MQTT: publish status, receive tare/calibrate commands
Display: live weight + sub-mode indicator + countdown timer
Auto-exit: 5 minutes (countdown ticks every second)
Sub-modes: tare / calibrate / diagnostics
OTA mode (triggered by IoT Jobs via MQTT)
WiFi: full power (forced)
LED: ota_pulse effect
Display: "UPDATING / Do not power off"
Normal operation suspended until reboot
State machine globals (in ScaleState struct, include/state.h)¶
operating_mode: "normal" | "maintenance" restore: no
maintenance_sub: "none" | "tare" | "calibrate" | "diagnostics"
maintenance_source: "none" | "physical" | "virtual" | "ble"
maintenance_countdown: int (300 seconds) restore: no
tare_empty_offset: float (raw HX711 counts) restore: yes (NVS)
tare_full_lbs: float (lbs) restore: yes (NVS)
scale_factor: float (counts/lb, factory set) restore: yes (NVS)
zero_offset: float (raw at zero load) restore: yes (NVS)
beer_name: string restore: yes (NVS)
keg_type: string ("half_barrel" etc) restore: yes (NVS)
specific_gravity: float (default 1.0) restore: yes (NVS)
tare_status: "pending" | "complete" restore: yes (NVS)
ble_connected: bool restore: no
ota_in_progress: bool restore: no
cal_zero_raw: float (calibration temp) restore: no
cal_known_weight: float (calibration temp) restore: no
GPIO pinout¶
| GPIO | Purpose | Notes |
|---|---|---|
| GPIO0 | HX711 DOUT | Load cell data |
| GPIO10 | HX711 CLK | Load cell clock |
| GPIO4 | Battery ADC | ADC1_CH4 (safe during WiFi), external 470kΩ/470kΩ divider. Never use GPIO5 (ADC2, disabled during WiFi) |
| GPIO8 | Status LED | Active high |
| GPIO7 | I2C SDA | OLED display |
| GPIO3 | I2C SCL | OLED display |
Battery circuit¶
- LiPo pouch cell (5000mAh, JST PH 2.0mm) with TP4056 charger
- Voltage regulator: HT7333 (3.3V LDO) — NOT AMS1117
- Divider: 470kΩ/470kΩ external, wired VBAT → GPIO4 → GND → ratio 0.5
- ADC reads max 2.10V at 4.2V full charge — well within 3.3V limit
- Attenuation: 11db (covers 0–3.3V range)
- Multiply filter: 2.0 (reverse the 0.5 divider)
Battery voltage to percentage calibration curve:
HX711 weight calculation¶
weight_lbs = (raw_value - zero_offset) / scale_factor
net_lbs = weight_lbs - tare_empty_offset
range_lbs = tare_full_lbs - tare_empty_offset
level_pct = clamp(net_lbs / range_lbs * 100, 0, 100)
net_kg = net_lbs * 0.453592
gallons = max(0, (net_kg / specific_gravity) * 0.264172)
Publish -1.0 for level_pct and gallons when tare_status == "pending". Lambda ignores negative values — never stores garbage in DynamoDB.
MQTT topics¶
| Topic | Direction | Purpose |
|---|---|---|
keg/readings/SCALE-0042 |
ESP32 → Pi | Weight readings JSON |
keg/status/SCALE-0042 |
ESP32 → Pi | Online/offline/maintenance status |
keg/response/SCALE-0042 |
ESP32 → Pi | Command acknowledgements |
keg/command/SCALE-0042/maintenance_on |
Pi → ESP32 | Enter maintenance |
keg/command/SCALE-0042/maintenance_off |
Pi → ESP32 | Exit maintenance |
keg/command/SCALE-0042/tare_empty |
Pi → ESP32 | Tare empty keg |
keg/command/SCALE-0042/tare_full |
Pi → ESP32 | Tare full keg |
keg/command/SCALE-0042/calibrate |
Pi → ESP32 | Calibrate (payload: known weight lbs) |
keg/command/SCALE-0042/config_update |
Pi → ESP32 | Beer/keg assignment JSON |
keg/command/SCALE-0042/report_now |
Pi → ESP32 | Immediate reading request |
keg/command/SCALE-0042/identify |
Pi → ESP32 | Flash LED |
keg/ota/SCALE-0042/pending |
Pi → ESP32 | OTA flag ("true"/"false") |
MQTT settings: - Broker: Pi local IP (192.168.x.x) - clean_session: false (retain subscriptions across sleep/reconnect) - Birth: keg/status/SCALE-0042 → "online" (retain: true) - Will: keg/status/SCALE-0042 → "offline" (retain: true)
Reading JSON payload¶
{
"device_id": "SCALE-0042",
"weight_lbs": 87.32,
"level_pct": 54.1,
"gallons": 8.4,
"battery_v": 3.94,
"battery_pct": 78,
"tare_status": "complete",
"sg": 1.014
}
BLE GATT service¶
Service UUID: 4FAFC201-1FB5-459E-8FCC-C5C9C331914B
| Characteristic UUID | Properties | Purpose |
|---|---|---|
BEB5483E-...-26A8 |
read + notify | Live weight stream (JSON: {"lbs": 87.3}) |
BEB5483E-...-26A9 |
write | Commands from phone |
BEB5483E-...-26AA |
read | Device info JSON on connect |
BLE command strings (written to command characteristic):
- "identify" → flash LED
- "tare_empty" → execute tare empty
- "tare_full" → execute tare full
- "maint_on" → enter maintenance (source: ble)
- "maint_off" → exit maintenance
- "cal:<weight_lbs>" → calibrate with known weight (e.g. "cal:10.0")
On BLE connect: auto-enter maintenance + identify flash On BLE disconnect: auto-exit maintenance
LED effects¶
Status LED (GPIO8): green on Olimex dev board (active-low), blue 0402 on production PCB (active-high). Charge LED: amber while charging, off when complete (TP4056 hardware-driven — no firmware involvement).
| Effect | Pattern | Meaning |
|---|---|---|
rapid_flash |
150ms on / 100ms off, 3× | Identify — look at this scale, then settle to slow_pulse |
slow_pulse |
1000ms on / 1000ms off | Maintenance mode active |
ota_pulse |
200ms on / 200ms off | OTA update in progress |
| off | — | Normal operation |
Button behavior¶
No physical button on ESP32-C3 production hardware. Maintenance is entered via BLE connect or MQTT command only.
Display states (SSD1306 128x32)¶
Normal — tare complete:
Normal — tare pending:
Maintenance mode:
OTA:
Tare procedure¶
- Remove all weight from scale
- Send
tare_emptycommand (via BLE or MQTT) - Firmware records
zero_offset= current raw HX711 value - Place full keg
- Send
tare_fullcommand - Firmware records
tare_full_lbs= calculated lbs from raw - Sets
tare_status= "complete" - Publishes confirmation to
keg/response/SCALE-0042 - Lambda writes tare refs to DynamoDB keg record
Calibration procedure¶
Two-command flow (phone orchestrates):
1. Remove all weight. Send cal:<known_weight> command
2. Firmware records cal_zero_raw at empty
3. Phone prompts user to place known weight
4. Firmware waits ~2s then takes loaded reading
5. Computes: new_scale_factor = (loaded_raw - cal_zero_raw) / known_weight
6. Stores new scale_factor and zero_offset to NVS
7. Publishes drift % to keg/response/SCALE-0042
8. Lambda writes calibration record to DynamoDB
Factory calibration: done at assembly with certified weight, burned to NVS. Field calibration: manager role minimum. Drift > 3% flags for recalibration. Drift > 8% suggests physical damage — replace scale.
OTA flow¶
- IoT Jobs delivers job to Pi (Greengrass)
- Pi fetches .bin from S3 presigned URL
- Pi publishes
keg/ota/SCALE-0042/pending= "true" - ESP32 receives, sets
ota_in_progress = true, forces WiFi full power - ESP32 fetches .bin from Pi OTA server over HTTP
- On success: LED off, reboot
- On failure:
ota_in_progress = false, resumes normal operation - Pi clears pending flag after success/failure
Known issues / gotchas¶
- ESP32 ADC2 pins (GPIO0, 2, 4, 12-15, 25-27) are unusable when WiFi active → Always use ADC1 pins (GPIO32-39) for battery ADC
- HX711 needs 400ms warmup after power-on before first stable reading
clean_session: falseis critical — command topics must persist across reconnects- Retained MQTT commands need explicit clear after acknowledgement (publish empty payload to same topic) to avoid re-execution on reconnect
- Display update must be called after every state change
- BLE + WiFi simultaneously: small throughput reduction, acceptable for our use case