Поставили ящик на весы, записали число, перенесли в таблицу — и так снова. Часть этой рутины можно собрать в одном устройстве: ESP32 читает HX711, показывает массу на OLED и по кнопке отправляет результат в Google Таблицу. Начнём с одного ящика и проследим весь путь записи.
Это лабораторная сборка для знакомства с измерением и учётом урожая. Точность выясняется испытанием всей платформы; для торговли и расчёта оплаты нужна отдельная оценка пригодности. Здесь наша цель — понятное взвешивание и проверяемая запись.
Собираем платформу и подключаем электронику
- ESP32-DevKitC на ESP32-WROOM с доступными GPIO16 и GPIO17.
- Модуль HX711 и один четырёхпроводный тензодатчик с полным мостом, совместимые по питанию и сигналам.
- Платформа и крепление по документации датчика, с учётом рабочего диапазона и перегрузки.
- OLED SSD1306, 128 × 64 точки, с питанием и логикой 3,3 В; в скетче выбран I2C-адрес 0x3C.
- Нормально разомкнутая кнопка, проводка, клеммы и стабильное питание.
HX711 VCC → 3V3; GND → GND (для совместимого модуля)
HX711 DT/DOUT → GPIO16; SCK → GPIO17
OLED VCC → 3V3; GND → GND; SDA → GPIO21; SCL → GPIO22
Кнопка → GPIO4 и GND
Тензодатчик: возбуждение → E+/E− HX711; сигнал → A+/A−
Сверьте распиновку конкретных изделий. У некоторых плат выводы заняты памятью, а у модулей HX711 разделены аналоговое и цифровое питание. Цвета проводов датчика не универсальны; на GPIO ESP32 и подтяжки I2C нельзя подавать 5 В.
Если используете четыре полумостовых датчика, потребуется их схема соединения или суммирующая плата. Грузоподъёмность выбирайте по всей механике: креплению, платформе и ограничителям. Проверьте, свободны ли кабель и корпус от нагрузки — иначе часть веса пройдёт мимо чувствительного элемента.
Пустой ящик и известный груз
- Откройте монитор порта на 115200 бод с окончанием строки. Снимите груз, дождитесь устойчивого raw и отправьте z. После каждого запуска ноль устанавливается вручную: автоматического tare() нет.
- Поставьте контрольный груз с достоверно известной массой. Объём бутылки или маркировка мешка не заменяют проверенную массу.
- После стабилизации отправьте c и массу через пробел, с десятичной точкой. Команда c 5.000 — пример для груза, масса которого действительно равна 5 кг.
- Коэффициент равен разности отсчётов с грузом и без него, делённой на массу. Его знак сохраняется, включая отрицательные значения; коэффициент записывается во флеш-память.
- Проверьте возврат к нулю, несколько масс по рабочему диапазону, повторяемость и разные места установки груза. Нелинейность сначала исследуют, а не маскируют подбором коэффициента.
Команда z позволяет вычесть массу пустого ящика. Подпишите для себя, с какой тарой установлен ноль, и проверьте его после замены ящика. Пока прежняя запись ждёт подтверждения, калибровка и новое взвешивание заблокированы: сначала завершим начатую операцию.
Для первого опыта заданы MIN_KG=0.05 и MAX_KG=20.0. Согласуйте диапазон с платформой и сервером. STABLE_SPAN_KG=0.02 задаёт допустимый разброс десяти измерений, а CAL_SPAN_RAW=1000 — начальный порог шума при калибровке. Подберите их по повторным взвешиваниям; число знаков на экране и устойчивость показания сами по себе ещё не описывают точность.
Готовим место для первого взвешивания
Создайте лист Weights с заголовками id, received_at, device, kg, uptime_ms. Столбец id храните как текст. В свойствах проекта Apps Script задайте SHEET_ID — идентификатор таблицы из её адреса — и INGEST_SECRET: собственную случайную строку из 64 шестнадцатеричных символов нижнего регистра.
Вставьте код ниже и разверните его как веб-приложение с выполнением от вашего имени и доступом для внешних запросов без входа в Google, если это разрешено аккаунтом и организацией. Проверку секрета выполняет сам обработчик. В SHEET_URL прошивки укажите адрес развёртывания с окончанием /exec; после правок обновляйте используемую версию развёртывания.
Таблицу и секрет храните приватно. Адрес обработчика открыт для запросов, а доступ проверяет сам скрипт; при постоянной работе следите за квотами и ошибками. Для начала заведите небольшой отдельный журнал и проверьте запись в него.
function reply(value) {
return ContentService.createTextOutput(JSON.stringify(value))
.setMimeType(ContentService.MimeType.JSON);
}
function doPost(e) {
const props = PropertiesService.getScriptProperties();
const secret = props.getProperty('INGEST_SECRET');
const spreadsheetId = props.getProperty('SHEET_ID');
let data;
try {
const raw = e && e.postData && e.postData.contents;
if (typeof raw !== 'string' || raw.length > 2048) return reply({ok:false,error:'bad_request'});
data = JSON.parse(raw);
if (!data || !/^[0-9a-f]{64}$/.test(secret || '') || data.secret !== secret)
return reply({ok:false,error:'unauthorized'});
if (typeof data.id !== 'string' || !/^w[0-9a-f]{32}$/.test(data.id) ||
typeof data.device !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(data.device) ||
typeof data.kg !== 'number' || !Number.isFinite(data.kg) || data.kg < 0.05 || data.kg > 20 ||
!Number.isInteger(data.uptime_ms) || data.uptime_ms < 0 || data.uptime_ms > 4294967295)
return reply({ok:false,error:'invalid_record'});
} catch (_) { return reply({ok:false,error:'bad_json'}); }
const lock = LockService.getScriptLock();
if (!lock.tryLock(3000)) return reply({ok:false,error:'busy'});
try {
const sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('Weights');
if (!sheet) return reply({ok:false,error:'missing_sheet'});
const header = sheet.getRange(1,1,1,5).getValues()[0];
if (header.join('|') !== 'id|received_at|device|kg|uptime_ms')
return reply({ok:false,error:'bad_header'});
const last = sheet.getLastRow();
const rows = last > 1 ? sheet.getRange(2,1,last-1,5).getValues() : [];
const existing = rows.find(row => row[0] === data.id);
if (existing) {
if (existing[2] !== data.device || Number(existing[3]) !== data.kg || Number(existing[4]) !== data.uptime_ms)
return reply({ok:false,error:'id_conflict'});
return reply({ok:true,id:data.id,duplicate:true});
}
sheet.appendRow([data.id,new Date(),data.device,data.kg,data.uptime_ms]);
SpreadsheetApp.flush();
return reply({ok:true,id:data.id,duplicate:false});
} catch (_) { return reply({ok:false,error:'storage_error'}); }
finally { lock.releaseLock(); }
}
Попробуйте отправить одно событие повторно: при совпадении идентификатора и данных новая строка появляться не должна. Другой вес с тем же идентификатором обработчик отклонит. Пока возможен повтор, сохраняйте строки и их идентификаторы; ручные правки и другие скрипты требуют отдельного согласования.
В столбцах будут два разных времени: received_at — получение сервером, uptime_ms — момент фиксации после запуска ESP32. Когда понадобится журнал смены, добавьте календарные часы, партию и оператора, а также права доступа и резервирование.
Что говорят надписи на экране
Сборка проверена с Arduino-ESP32 3.3.12, HX711 Arduino Library 0.7.5 авторства Bogdan Necula, Adafruit SSD1306 2.5.16, Adafruit GFX Library 1.12.6, Adafruit BusIO 1.17.4 и ArduinoJson 7.4.3. Не подменяйте HX711 другой одноимённой библиотекой. Заполните SSID, PASSWORD, SHEET_URL, SHARED_SECRET и уникальное имя DEVICE.
Следите за тремя надписями OLED: SAVED LOCALLY — запись у устройства, PENDING — ждёт ответа, SERVER CONFIRMED — подтверждена сервером. При STORAGE ERROR сначала разбираемся с памятью. Латинские подписи позволяют использовать экран без дополнительного шрифта.
#include <Arduino.h>
#include <HX711.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <esp_system.h>
#include <time.h>
#include <math.h>
constexpr char SSID[]="CHANGE_ME", PASSWORD[]="CHANGE_ME";
constexpr char SHEET_URL[]="https://script.google.com/macros/s/CHANGE_ME/exec";
constexpr char SHARED_SECRET[]="CHANGE_ME"; // 64 случайных шестнадцатеричных символа.
constexpr char DEVICE[]="scale-01";
constexpr int DT_PIN=16, SCK_PIN=17, BUTTON_PIN=4;
constexpr double MAX_KG=20.0, MIN_KG=0.05, STABLE_SPAN_KG=0.02;
constexpr long CAL_SPAN_RAW=1000;
constexpr uint32_t FRESH_MS=1200, RETRY_MS=60000;
constexpr size_t WINDOW=10;
HX711 hx;Adafruit_SSD1306 display(128,64,&Wire,-1);Preferences prefs;
bool storageOk=false,screenOk=false,zeroed=false;
double factor=0,zeroRaw=0;
String pending,statusText="ZERO REQUIRED",serialLine;
uint32_t triedAt=0,drawAt=0,buttonChanged=0;
bool tried=false,buttonRaw=HIGH,buttonStable=HIGH;
struct Samples {
long values[WINDOW]{};size_t n=0,pos=0;uint32_t at=0;
void clear(){n=0;pos=0;}
void add(long value,uint32_t now){
if(value<=-8388608L || value>=8388607L){clear();return;}
if(n && uint32_t(now-at)>FRESH_MS)clear();
values[pos]=value;pos=(pos+1)%WINDOW;if(n<WINDOW)++n;at=now;
}
bool stats(uint32_t now,double& mean,double& span)const{
if(n!=WINDOW || uint32_t(now-at)>FRESH_MS)return false;
long lo=values[0],hi=values[0];int64_t sum=0;
for(size_t i=0;i<WINDOW;i++){sum+=values[i];if(values[i]<lo)lo=values[i];if(values[i]>hi)hi=values[i];}
mean=double(sum)/WINDOW;span=double(hi)-lo;return true;
}
} samples;
bool weightNow(uint32_t now,double& kg){
double mean,span;
if(!zeroed || !isfinite(factor) || fabs(factor)<1 || !samples.stats(now,mean,span))return false;
kg=(mean-zeroRaw)/factor;
return isfinite(kg) && span/fabs(factor)<=STABLE_SPAN_KG;
}
bool validId(const String& id){
if(id.length()!=33 || id[0]!='w')return false;
for(size_t i=1;i<33;i++)if(!((id[i]>='0'&&id[i]<='9')||(id[i]>='a'&&id[i]<='f')))return false;
return true;
}
bool validRecord(const String& record){
JsonDocument d;if(deserializeJson(d,record))return false;
return validId(d["id"].as<String>()) && d["device"].as<String>()==DEVICE
&& d["kg"].is<double>() && isfinite(d["kg"].as<double>())
&& d["kg"].as<double>()>=MIN_KG && d["kg"].as<double>()<=MAX_KG
&& d["uptime_ms"].is<uint32_t>();
}
void captureWeight(uint32_t now){
if(!screenOk){statusText="DISPLAY ERROR";return;}
double kg;
if(!storageOk){statusText="STORAGE ERROR";return;}
if(pending.length()){statusText="WAIT: PENDING";return;}
if(!weightNow(now,kg)){statusText="NOT STABLE/ZERO";return;}
if(kg<MIN_KG || kg>MAX_KG){statusText="OUT OF RANGE";return;}
char id[34];snprintf(id,sizeof(id),"w%08lx%08lx%08lx%08lx",(unsigned long)esp_random(),(unsigned long)esp_random(),(unsigned long)esp_random(),(unsigned long)esp_random());
JsonDocument d;d["id"]=id;d["device"]=DEVICE;d["kg"]=round(kg*1000.0)/1000.0;d["uptime_ms"]=now;
String record;serializeJson(d,record);
if(!validRecord(record) || prefs.putString("pending",record)!=record.length() || prefs.getString("pending","")!=record){
storageOk=false;statusText="STORAGE ERROR";return;
}
pending=record;statusText="SAVED LOCALLY";tried=false;
}
bool uploadPending(){
if(WiFi.status()!=WL_CONNECTED || time(nullptr)<1735689600 || strlen(SHARED_SECRET)!=64)return false;
JsonDocument request;if(deserializeJson(request,pending))return false;
String id=request["id"].as<String>();request["secret"]=SHARED_SECRET;
String payload;serializeJson(request,payload);
NetworkClientSecure client;client.useBuiltinCACertBundle();client.setHandshakeTimeout(5);
HTTPClient http;http.setConnectTimeout(3000);http.setTimeout(5000);http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
const char* headers[]={"Location"};http.collectHeaders(headers,1);
if(!http.begin(client,SHEET_URL))return false;
http.addHeader("Content-Type","application/json");int code=http.POST(payload);
String location=http.header("Location"),response=http.getString();http.end();
if(code==302 || code==303){
// Получаем ответ ContentService; секрет повторно не передаём.
if(!location.startsWith("https://script.googleusercontent.com/"))return false;
if(!http.begin(client,location))return false;
code=http.GET();response=http.getString();http.end();
}
JsonDocument answer;
return code==200 && !deserializeJson(answer,response) && answer["ok"].is<bool>()
&& answer["ok"].as<bool>() && answer["id"].as<String>()==id;
}
void command(String line){
line.trim();double mean,span;
if(!storageOk || pending.length()){statusText="WAIT/STORAGE";return;}
if(!samples.stats(millis(),mean,span) || span>CAL_SPAN_RAW){statusText="RAW NOT STABLE";return;}
if(line=="z"){
zeroRaw=mean;zeroed=true;samples.clear();statusText="ZERO SET";
}else if(line.startsWith("c ") && zeroed){
char* end=nullptr;double known=strtod(line.c_str()+2,&end);
double candidate=(mean-zeroRaw)/known;
if(end==line.c_str()+2 || *end || !isfinite(known) || known<=0 || known>MAX_KG || !isfinite(candidate) || fabs(mean-zeroRaw)<1000 || fabs(candidate)<1){statusText="BAD CALIBRATION";return;}
if(prefs.putDouble("factor",candidate)!=sizeof(double) || prefs.getDouble("factor",0)!=candidate){storageOk=false;statusText="STORAGE ERROR";return;}
factor=candidate;samples.clear();statusText="CALIBRATED";
}
}
void draw(){
if(!screenOk)return;
display.clearDisplay();display.setCursor(0,0);display.setTextSize(1);display.setTextColor(SSD1306_WHITE);
double kg,mean,span;
if(weightNow(millis(),kg))display.printf("kg: %.3f\n",kg);else display.println("WAIT / NO VALID kg");
if(samples.stats(millis(),mean,span))display.printf("raw: %.0f\n",mean);
display.println(statusText);display.println(pending.length()?"PENDING IN FLASH":"NO PENDING RECORD");
display.println("Serial: z / c 5.000");display.display();
}
void setup(){
Serial.begin(115200);pinMode(BUTTON_PIN,INPUT_PULLUP);
Wire.begin(21,22);screenOk=display.begin(SSD1306_SWITCHCAPVCC,0x3C);
hx.begin(DT_PIN,SCK_PIN); // Не выполняем tare() при запуске.
storageOk=prefs.begin("crop-scale",false);
if(storageOk){
factor=prefs.getDouble("factor",0);pending=prefs.getString("pending","");
if(prefs.isKey("pending") && !validRecord(pending))storageOk=false;
}
if(!storageOk)statusText="STORAGE ERROR";
WiFi.mode(WIFI_STA);WiFi.setAutoReconnect(true);WiFi.begin(SSID,PASSWORD);
configTime(0,0,"pool.ntp.org","time.cloudflare.com");
Serial.println("Remove load. After stable raw send z. Calibrate with c MASS_KG.");
}
void loop(){
if(hx.is_ready())samples.add(hx.read(),millis());
while(Serial.available()){
char c=Serial.read();if(c=='\n'){command(serialLine);serialLine="";}
else if(c!='\r'){if(serialLine.length()<40)serialLine+=c;else serialLine="";}
}
uint32_t now=millis();bool b=digitalRead(BUTTON_PIN);
if(b!=buttonRaw){buttonRaw=b;buttonChanged=now;}
if(uint32_t(now-buttonChanged)>=40 && b!=buttonStable){buttonStable=b;if(b==LOW)captureWeight(now);}
if(storageOk && pending.length() && WiFi.status()==WL_CONNECTED && (!tried || uint32_t(now-triedAt)>=RETRY_MS)){
tried=true;triedAt=now;statusText="SENDING...";draw();
if(uploadPending()){
if(prefs.remove("pending") && !prefs.isKey("pending")){pending="";statusText="SERVER CONFIRMED";}
else{storageOk=false;statusText="STORAGE ERROR";}
}else statusText="PENDING: NO ACK";
samples.clear();buttonRaw=buttonStable=digitalRead(BUTTON_PIN);buttonChanged=millis();
}
if(uint32_t(millis()-drawAt)>=250){drawAt=millis();draw();}
delay(1);
}
Во флеш-памяти хранится одна ожидающая запись. Новое нажатие её не затрёт: контроллер ждёт HTTP 200, ok=true и тот же идентификатор. Если ответ потерялся, повтор отправится с прежним номером. Для непрерывной приёмки без сети следующим расширением станет очередь на несколько записей.
HTTPS проверяет сертификат и ожидает синхронизации времени. Ответ Google ContentService после перенаправления запрашивается отдельным GET только у script.googleusercontent.com, без повторной передачи секрета. Попытки разделены минутой. Синхронный запрос приостанавливает опрос весов; затем окно измерений очищается и требуется новое нажатие кнопки.
Контроллер отбрасывает отсчёты старше 1200 мс и крайние коды ADC. Если работа заблокирована, сначала сравните сохранённую запись с таблицей. Смена DEVICE, очистка памяти или ручная правка журнала в этот момент могут нарушить связь между ними.
Проверяем весы разными грузами
Начните со стабильного USB-питания. Для ESP32-DevKitC V4 варианты USB, 5V/GND и 3V3/GND взаимоисключающие. Аккумуляторный источник должен обеспечивать нужное напряжение и пиковый ток во всём рабочем диапазоне разряда; время работы измеряют на полной сборке.
При переходе на аккумулятор подберите отдельные зарядник, защиту и преобразователь. TP4056 выполняет зарядку; стабильное питание ESP32 обеспечивает соответствующий узел. У корпуса продумайте воду и конденсат, а рабочую часть тензодатчика оставьте свободной от материалов, меняющих передачу нагрузки.
- Проверьте контрольные массы, разные точки платформы, возврат к нулю и фактические отклонения. Число знаков на экране не заменяет эти измерения.
- Включите устройство с грузом: без ручного нуля новая запись должна оставаться недоступной.
- Зафиксируйте массу без сети. Проверьте локальное сохранение, запрет замены записи, перезапуск и одну строку после восстановления связи.
- Испытайте повтор запроса, неверный секрет, недопустимую массу и отсутствие листа. Отказ не должен выглядеть как подтверждение.
- Проверьте отключение датчика, неустойчивость, нажатия во время сетевого ожидания и отказ экрана. Оператор должен видеть результат каждого принятого нажатия.
Проведите несколько взвешиваний одного груза, меняя его положение на платформе. Затем уберите груз и проверьте возврат к нулю. Вместе с опытами отключения сети это даст наглядную картину: как ведёт себя механика и где находится каждая запись.
Из одной строки — в журнал приёмки
Следующим шагом могут стать номер партии, оператор, RFID или итоги смены. Добавляйте их к уже понятному взвешиванию. Для пользователей Home Assistant есть и компонент HX711 в ESPHome со своей калибровкой; хранение и подтверждение записей тогда проектируют под выбранную систему.
Первичные источники: библиотека HX711, устройство модуля, Preferences, веб-приложения Apps Script, ContentService, LockService.
Для работы вдали от розетки: Автономное питание ESP32.
Ещё один проект с передачей показаний: Датчик микроклимата с проверкой доставки уведомлений.





