Skip to content

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:

- 3.00 -> 0
- 3.40 -> 18
- 3.60 -> 40
- 3.80 -> 65
- 4.00 -> 87
- 4.20 -> 100

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:

Guinness Draught
[████████░░░░] 54%  78%bat

Normal — tare pending:

Sierra Nevada Pale
TARE REQUIRED

Maintenance mode:

TARE MODE         (or CALIBRATE / DIAG MODE / MAINTENANCE)
0.3 lb ●Stable        4:12

OTA:

UPDATING
Do not power off

Tare procedure

  1. Remove all weight from scale
  2. Send tare_empty command (via BLE or MQTT)
  3. Firmware records zero_offset = current raw HX711 value
  4. Place full keg
  5. Send tare_full command
  6. Firmware records tare_full_lbs = calculated lbs from raw
  7. Sets tare_status = "complete"
  8. Publishes confirmation to keg/response/SCALE-0042
  9. 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

  1. IoT Jobs delivers job to Pi (Greengrass)
  2. Pi fetches .bin from S3 presigned URL
  3. Pi publishes keg/ota/SCALE-0042/pending = "true"
  4. ESP32 receives, sets ota_in_progress = true, forces WiFi full power
  5. ESP32 fetches .bin from Pi OTA server over HTTP
  6. On success: LED off, reboot
  7. On failure: ota_in_progress = false, resumes normal operation
  8. 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: false is 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