Где побывала техника: начинаем с первой точки
Хочется увидеть маршрут машины за день и вернуться к отдельному участку пути? Соберём лабораторный трекер на ESP32, NEO-6M и SIM800L. Сначала получим координаты на столе, сохраним их на microSD и отправим в Google Таблицу. Затем попробуем геозону с сообщением в Telegram; установку на технику подготовим после этих опытов.
В журнале будут координаты и скорость. Расход топлива, состояние двигателя и моточасы потребуют своих источников данных. Начнём с вопроса, на который GPS действительно помогает ответить: где находился приёмник в момент измерения?
Собираем комплект и проверяем питание
Нужны классическая плата ESP32 с модулем WROOM, плата GPS-приёмника NEO-6M с антенной, SIM800L с GSM-антенной, SIM-карта с передачей данных, microSD и её адаптер с логическими уровнями 3,3 В. Также потребуются согласование UART модема, отдельные цепи питания, разъёмы и корпус. Распиновка ниже не предназначена для ESP32-C3/C6 или плат с PSRAM, занимающей выбранные выводы.
Если детали уже есть, начните с проверки интерфейсов и антенн. При новой покупке сравните поддерживаемые приёмники: NEO-6 относится к старому поколению. Для SIM800L уточните у оператора 2G/GPRS на своих полях, APN и тариф. В смету включите питание, монтаж, связь и обслуживание.
По документации SIM800L, диапазон VBAT составляет 3,4–4,4 В, типовое значение — 4,0 В, а импульсы тока при передаче достигают 2 А. Это требования к модулю; вход готовой платы проверяйте по её схеме. Нужен источник, выдерживающий импульсную нагрузку, и короткая цепь питания с развязкой по рекомендациям производителя. Проверяйте просадку напряжения у модуля во время передачи. Дополнительный конденсатор сам по себе не исправляет слабый источник.
На стенде используйте регулируемый источник с ограничением тока и отдельные подходящие линии для ESP32 и модема. Один обычный LM2596 не создаёт два независимо регулируемых выхода. Голый SIM800L нельзя питать от 5 В; UART модуля имеет уровни около 2,8 В и требует согласования с ESP32. Напряжение питания GPS зависит от конкретной платы, а наличие на ней стабилизатора не означает, что сигнальные входы допускают 5 В.
Для установки на технику нужен отдельный проект питания от бортсети: защита от переполюсовки и выбросов, предохранитель по проводке, подходящие преобразователи и крепление. Пример TI для автомобильного входа показывает, почему лабораторный DC-DC нельзя считать достаточной защитой. До этой проверки отлаживайте устройство на столе. Корпус и кабельные вводы должны соответствовать месту установки; не зажимайте провод дверью и не прячьте антенну под металлом.
Соединяем модули и знакомимся с их ответами
Соединяйте модули при отключённом питании. TX и RX ниже — обозначения на соответствующем модуле. Земли соединяются; модем не питается от 3V3 ESP32. Согласуйте уровни UART SIM800L в обоих направлениях.
GPS TX → ESP32 GPIO16 (RX UART2)
GPS RX ← ESP32 GPIO17 (TX UART2; можно не подключать для приёма NMEA)
SIM800L RX ← ESP32 GPIO26 (TX UART1, через согласование уровней)
SIM800L TX → ESP32 GPIO27 (RX UART1, через согласование уровней)
microSD SCK → GPIO18; MISO → GPIO19; MOSI → GPIO23; CS → GPIO13
GND всех модулей → общая земля
Пример ожидает GPS на 9600 бод с сообщениями RMC и GGA примерно раз в секунду; проверьте реальную настройку своего приёмника. Модем должен быть включён по инструкции платы и отвечать на AT на 115200 бод. Скетч не управляет PWRKEY и аппаратным сбросом. SIM-карта должна быть подготовлена для передачи данных и работать без запроса PIN. Проверьте регистрацию и доступ в сеть отдельно, прежде чем разбирать ошибки Google Таблицы.
Готовим таблицу для первой записи
Создайте закрытую таблицу и проект Google Apps Script. Скопируйте приведённый ниже Code.gs целиком. В настройках проекта, в свойствах скрипта, задайте SPREADSHEET_ID — идентификатор таблицы из её адреса; DEVICE_SECRETS — JSON вида {"tractor1":"ВАШ_СЕКРЕТ"}; FENCE — {"enabled":false}. Для каждого устройства сгенерируйте отдельный случайный секрет командой ниже: результат содержит 64 шестнадцатеричных символа. Его же впишите в SECRET прошивки. Не публикуйте секрет, токен бота и готовую прошивку с ними.
openssl rand -hex 32
Выберите DEVICE: до 24 символов, сначала строчная латинская буква, затем буквы, цифры, дефис или подчёркивание. То же имя укажите в DEVICE_SECRETS. Сервер поддерживает до восьми устройств и создаёт лист GPS с нужными колонками; сохраняйте их названия и порядок.
По инструкции Google разверните веб-приложение с выполнением от имени владельца и доступом для клиентов без входа в Google. Этот режим может быть запрещён политикой вашей организации. В прошивку нужен адрес развёртывания, заканчивающийся на /exec, а не тестовый /dev. Таблица остаётся закрытой. После правки кода обновляйте версию развёртывания. Функция doPost проверяет секрет. Обработка посторонних запросов тоже расходует квоту Google.
Помимо квот Google, здесь действует авторский предел журнала: 10 000 строк данных. После заполнения сервер отклоняет новые записи. Не удаляйте старые строки ради освобождения места, пока возможны повторы: по ним сервер узнаёт уже полученные записи. Для постоянной эксплуатации нужен отдельный план архивирования с сохранением идентификаторов доставленных записей или другой сервер.
const HEADERS = ['id','sha256','device','received_utc','fix_utc','valid','lat','lng','speed_kmh','motion','hdop','sats','km_since_boot','uptime_ms'];
const MAX_ROWS = 10000; // Лимит учебного журнала, а не квота Google.
function reply(value) {
return ContentService.createTextOutput(JSON.stringify(value)).setMimeType(ContentService.MimeType.JSON);
}
function config() {
const props = PropertiesService.getScriptProperties();
const secrets = JSON.parse(props.getProperty('DEVICE_SECRETS') || '{}');
const devices = Object.keys(secrets);
if (!devices.length || devices.length > 8 || devices.some(id =>
!/^[a-z][a-z0-9_-]{0,23}$/.test(id) || !/^[0-9a-f]{64}$/.test(secrets[id]))) throw Error('config');
const fence = JSON.parse(props.getProperty('FENCE') || '{"enabled":false}');
if (fence.enabled && (!['latMin','latMax','lngMin','lngMax','margin'].every(k => Number.isFinite(fence[k])) ||
fence.latMin < -90 || fence.latMax > 90 || fence.lngMin < -180 || fence.lngMax > 180 ||
fence.margin <= 0 || fence.latMax - fence.latMin <= 2*fence.margin || fence.lngMax - fence.lngMin <= 2*fence.margin)) throw Error('fence');
return {props, secrets, devices, fence};
}
function validate(d, c, now) {
if (!d || Array.isArray(d) || typeof d.device !== 'string' || !Object.prototype.hasOwnProperty.call(c.secrets, d.device) ||
typeof d.secret !== 'string' || d.secret !== c.secrets[d.device] ||
typeof d.id !== 'string' || !/^[0-9a-f]{32}-[0-9]{10}$/.test(d.id) || typeof d.valid !== 'boolean') throw Error('request');
if (!['utc','uptime','sats'].every(k => Number.isSafeInteger(d[k]) && d[k] >= 0) || d.uptime > 4294967295 ||
!['lat','lng','speed','hdop','km'].every(k => typeof d[k] === 'number' && Number.isFinite(d[k])) || d.km < 0 || d.km > 10000000) throw Error('numbers');
if (d.valid) {
if (d.utc < 1735689600 || d.utc > now+10 || Math.abs(d.lat)>90 || Math.abs(d.lng)>180 ||
d.speed<0 || d.speed>120 || d.hdop<=0 || d.hdop>2.5 || d.sats<4 || d.sats>99) throw Error('fix');
} else if (d.utc || d.lat || d.lng || d.speed || d.hdop || d.sats) throw Error('invalid fix');
return {id:d.id,device:d.device,utc:d.utc,uptime:d.uptime,valid:d.valid,lat:d.lat,lng:d.lng,
speed:d.speed,hdop:d.hdop,sats:d.sats,km:d.km}; // Секрет не сохраняется.
}
function digest(p) {
return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, JSON.stringify(p), Utilities.Charset.UTF_8)
.map(x => ('0'+((x+256)%256).toString(16)).slice(-2)).join('');
}
function region(p, f) {
if (p.lat<f.latMin-f.margin || p.lat>f.latMax+f.margin || p.lng<f.lngMin-f.margin || p.lng>f.lngMax+f.margin) return 'outside';
if (p.lat>f.latMin+f.margin && p.lat<f.latMax-f.margin && p.lng>f.lngMin+f.margin && p.lng<f.lngMax-f.margin) return 'inside';
return null; // Полоса неопределённости возле границы.
}
function updateFence(p, c, now) {
if (!c.fence.enabled) return;
const key='geo:'+p.device;
if (!p.valid) { c.props.setProperty(key,JSON.stringify({last:0,stable:null})); return; }
if (now-p.utc>120 || p.utc>now+5) return;
let s=JSON.parse(c.props.getProperty(key)||'{"last":0,"stable":null}');
if (p.utc<=s.last) return;
if (p.utc-s.last>120) s={last:0,stable:null};
const r=region(p,c.fence);
if (!r) { s.candidate=null; s.count=0; }
else {
if (r!==s.candidate) { s.candidate=r; s.count=1; s.since=p.utc; }
else s.count++;
if (s.count>=2 && p.utc-s.since>=30 && s.stable!==r) {
const initial=s.stable===null;
s.stable=r;
s.pending=initial && r==='inside' ? null : {utc:p.utc,region:r,
text:`${p.device}: ${r==='outside'?'наблюдается вне геозоны':'наблюдается внутри геозоны'}. `+
`Измерение ${new Date(p.utc*1000).toISOString()}. Проверьте положение.`};
}
}
s.last=p.utc; c.props.setProperty(key,JSON.stringify(s));
}
function doPost(e) {
const lock=LockService.getScriptLock();
try {
if (!e || !e.postData || typeof e.postData.contents!=='string' || e.postData.contents.length>1500) return reply({ok:false});
const c=config(), now=Math.floor(Date.now()/1000), p=validate(JSON.parse(e.postData.contents),c,now), hash=digest(p);
if (!lock.tryLock(5000)) return reply({ok:false});
const book=SpreadsheetApp.openById(c.props.getProperty('SPREADSHEET_ID'));
let sheet=book.getSheetByName('GPS');
if (!sheet) { sheet=book.insertSheet('GPS'); sheet.appendRow(HEADERS); }
const rows=sheet.getLastRow();
if (rows<1 || rows>MAX_ROWS+1 || JSON.stringify(sheet.getRange(1,1,1,HEADERS.length).getValues()[0])!==JSON.stringify(HEADERS)) throw Error('sheet');
const found=rows>1 ? sheet.getRange(2,1,rows-1,2).getValues().find(r=>r[0]===p.id) : null;
if (found && found[1]!==hash) throw Error('id collision');
if (!found) {
if (rows>=MAX_ROWS+1) throw Error('journal full');
sheet.appendRow([p.id,hash,p.device,new Date(),p.valid?new Date(p.utc*1000):'',p.valid,p.valid?p.lat:'',p.valid?p.lng:'',p.valid?p.speed:'',p.valid?(p.speed>3?'moving':'low_speed'):'no_fix',p.valid?p.hdop:'',p.valid?p.sats:'',p.km,p.uptime]);
SpreadsheetApp.flush();
}
updateFence(p,c,now);
return reply({ok:true,id:p.id});
} catch (_) { return reply({ok:false}); }
finally { if (lock.hasLock()) lock.releaseLock(); }
}
function doGet() { return reply({ok:false}); }
function flushAlerts() {
const lock=LockService.getScriptLock(); if (!lock.tryLock(1000)) return;
try {
const c=config(), token=c.props.getProperty('TG_TOKEN'), chat=c.props.getProperty('TG_CHAT');
if (!c.fence.enabled || !token || !chat) return;
for (const device of c.devices) {
const key='geo:'+device, s=JSON.parse(c.props.getProperty(key)||'{}'), p=s.pending;
if (!p) continue;
const now=Math.floor(Date.now()/1000);
if (now-p.utc>120 || now-s.last>120 || p.region!==s.stable) { s.pending=null; c.props.setProperty(key,JSON.stringify(s)); continue; }
try {
const r=UrlFetchApp.fetch('https://api.telegram.org/bot'+token+'/sendMessage',{
method:'post',contentType:'application/json',payload:JSON.stringify({chat_id:chat,text:p.text}),
muteHttpExceptions:true,followRedirects:false,validateHttpsCertificates:true});
const answer=JSON.parse(r.getContentText());
if (r.getResponseCode()===200 && answer.ok===true) {
s.pending=null; c.props.setProperty(key,JSON.stringify(s));
}
} catch (_) { /* Повтор на следующем запуске, если событие ещё актуально. */ }
}
} finally { lock.releaseLock(); }
}
function installAlertTrigger() {
for (const t of ScriptApp.getProjectTriggers()) if (t.getHandlerFunction()==='flushAlerts') ScriptApp.deleteTrigger(t);
ScriptApp.newTrigger('flushAlerts').timeBased().everyMinutes(1).create();
}
Загружаем программу и ждём координаты
Скетч проверен компиляцией для ESP32 Dev Module: пакет Arduino-ESP32 3.3.12, TinyGPSPlus 1.0.3, ArduinoJson 7.4.3. Остальные библиотеки входят в пакет ESP32. Вставьте свой APN, DEVICE, SECRET и адрес /exec. Возьмите отдельную исправную карту microSD с поддерживаемой библиотекой SD файловой системой FAT; сохраните её прежние данные перед подготовкой. Каталоги queue и sent создаются программой.
SIM800L предоставляет сотовое соединение через PPP, а HTTPS выполняется на ESP32 с проверкой сертификата. Это позволяет обращаться к Google без отправки секрета по открытому HTTP. Нужны рабочие DNS и синхронизация часов через NTP. ContentService Google перенаправляет ответ на script.googleusercontent.com: код допускает один такой переход и получает результат отдельным GET без повторной отправки секрета. Доставкой считается только HTTP 200 с ok:true и совпадающим id.
#include <Arduino.h>
#include <PPP.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <TinyGPS++.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <esp_random.h>
#include <time.h>
#include <atomic>
const char *DEVICE = "tractor1", *APN = "CHANGE_ME";
const char *SECRET = "CHANGE_ME"; // 64 шестнадцатеричных символа.
const char *SHEET_URL = "https://script.google.com/macros/s/CHANGE_ME/exec";
constexpr uint32_t PERIOD = 60000;
constexpr int SD_CS = 13, MAX_PENDING = 120;
struct Point { uint32_t utc, uptime, seq; double lat, lng, km; float speed, hdop; uint32_t sats; bool valid; };
QueueHandle_t samples;
std::atomic<uint32_t> lost{0};
HardwareSerial gpsSerial(2);
TinyGPSPlus gps;
TinyGPSCustom rmcGP(gps, "GPRMC", 2), rmcGN(gps, "GNRMC", 2);
TinyGPSCustom ggaGP(gps, "GPGGA", 6), ggaGN(gps, "GNGGA", 6);
String boot;
bool storageOK = false, modemStarted = false;
uint32_t netTry = 0, sendTry = 0, statusAt = 0;
uint32_t gpsEpoch() {
if (!gps.date.isValid() || !gps.time.isValid() || gps.date.age() > 1500 || gps.time.age() > 1500) return 0;
int y = gps.date.year(), m = gps.date.month(), d = gps.date.day();
int h = gps.time.hour(), n = gps.time.minute(), s = gps.time.second();
if (y < 2025 || y > 2099 || m < 1 || m > 12 || d < 1 || d > 31 || h > 23 || n > 59 || s > 59) return 0;
tm t = {}; t.tm_year = y - 1900; t.tm_mon = m - 1; t.tm_mday = d;
t.tm_hour = h; t.tm_min = n; t.tm_sec = s;
time_t epoch = mktime(&t); tm check = {}; gmtime_r(&epoch, &check);
if (check.tm_year != y - 1900 || check.tm_mon != m - 1 || check.tm_mday != d ||
check.tm_hour != h || check.tm_min != n || check.tm_sec != s) return 0;
return uint32_t(epoch);
}
bool statusOK(TinyGPSCustom &a, TinyGPSCustom &b, const char *allowed) {
TinyGPSCustom &v = a.age() <= b.age() ? a : b;
const char *value = v.value();
return v.isValid() && v.age() <= 1500 && strlen(value) == 1 && strchr(allowed, value[0]);
}
bool readFix(Point &p) {
if (!statusOK(rmcGP, rmcGN, "A") || !statusOK(ggaGP, ggaGN, "12")) return false;
if (!gps.location.isValid() || gps.location.age() > 1500 || !gps.speed.isValid() || gps.speed.age() > 1500 ||
!gps.hdop.isValid() || gps.hdop.age() > 1500 || !gps.satellites.isValid() || gps.satellites.age() > 1500) return false;
p.utc = gpsEpoch(); p.lat = gps.location.lat(); p.lng = gps.location.lng();
p.speed = gps.speed.kmph(); p.hdop = gps.hdop.hdop(); p.sats = gps.satellites.value();
return p.utc && isfinite(p.lat) && isfinite(p.lng) && isfinite(p.speed) && isfinite(p.hdop) &&
fabs(p.lat) <= 90 && fabs(p.lng) <= 180 && p.speed >= 0 && p.speed <= 120 &&
p.hdop > 0 && p.hdop <= 2.5 && p.sats >= 4 && p.sats <= 99;
}
void captureTask(void *) {
Point latest = {}, previous = {}; bool havePrevious = false;
uint32_t fixAt = 0, savedAt = millis(), seq = 0; double km = 0;
for (;;) {
unsigned bytes = 0;
while (gpsSerial.available() && bytes++ < 2048) gps.encode(gpsSerial.read());
Point next = {};
if (readFix(next)) {
if (next.utc != latest.utc) {
bool continuous = havePrevious && next.utc > previous.utc && next.utc - previous.utc <= 5;
double meters = continuous ? TinyGPSPlus::distanceBetween(previous.lat, previous.lng, next.lat, next.lng) : 0;
if (continuous && meters > 40.0 * (next.utc - previous.utc) + 10) {
havePrevious = false; latest = next; latest.valid = false;
} else {
if (continuous && previous.speed > 3 && next.speed > 3) km += meters / 1000.0;
next.valid = true; next.km = km; latest = next; previous = next;
havePrevious = true; fixAt = millis();
}
}
} else { havePrevious = false; latest.valid = false; }
uint32_t now = millis();
if (uint32_t(now - savedAt) >= PERIOD) {
savedAt = now;
Point record = latest;
record.valid = latest.valid && uint32_t(now - fixAt) <= 2000;
if (!record.valid) { record.utc = 0; record.lat = record.lng = 0; record.speed = record.hdop = 0; record.sats = 0; }
record.km = km; record.uptime = now; record.seq = seq++;
if (xQueueSend(samples, &record, 0) != pdTRUE) lost.fetch_add(1);
if (!seq) vTaskDelete(nullptr); // Не допускаем повторения номера в одном запуске.
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
String encodePoint(const Point &p) {
char id[48]; snprintf(id, sizeof(id), "%s-%010lu", boot.c_str(), (unsigned long)p.seq);
JsonDocument d;
d["id"] = id; d["device"] = DEVICE; d["utc"] = p.utc; d["uptime"] = p.uptime;
d["valid"] = p.valid; d["lat"] = p.lat; d["lng"] = p.lng; d["speed"] = p.speed;
d["hdop"] = p.hdop; d["sats"] = p.sats; d["km"] = p.km;
String body; serializeJson(d, body); return body;
}
uint32_t crc32(const String &s) {
uint32_t crc = 0xffffffff;
for (unsigned char c : s) {
crc ^= c;
for (unsigned i = 0; i < 8; ++i) crc = (crc >> 1) ^ (0xedb88320U & (0U - (crc & 1)));
}
return ~crc;
}
String envelope(const String &body) {
char suffix[10]; snprintf(suffix, sizeof(suffix), "\n%08lx", (unsigned long)crc32(body));
return body + suffix;
}
bool readPacket(const String &path, String &body) {
File f = SD.open(path, FILE_READ);
if (!f || f.size() < 10 || f.size() > 1024) { if (f) f.close(); return false; }
String all = f.readString(); f.close();
body = all.substring(0, all.length() - 9);
return envelope(body) == all;
}
int pendingCount(String &first) {
File dir = SD.open("/queue"); if (!dir || !dir.isDirectory()) return -1;
int count = 0; first = "";
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
String name = f.name(); bool regular = !f.isDirectory(); f.close();
if (!regular || !name.endsWith(".pkt")) { dir.close(); return -1; }
++count; if (first.isEmpty() || name < first) first = name;
}
dir.close(); return count;
}
bool savePoint(const Point &p) {
String first; int count = pendingCount(first); if (count < 0 || count >= MAX_PENDING) return false;
String body = encodePoint(p); JsonDocument d;
if (deserializeJson(d, body)) return false;
char stamp[12]; snprintf(stamp, sizeof(stamp), "%010lu-", (unsigned long)p.utc);
String path = "/queue/" + String(stamp) + d["id"].as<String>() + ".pkt";
String temp = path + ".tmp", all = envelope(body);
if (SD.exists(path) || SD.exists(temp)) return false;
File f = SD.open(temp, FILE_WRITE); if (!f) return false;
size_t written = f.print(all); f.flush(); f.close();
String check;
if (written != all.length() || !readPacket(temp, check) || check != body) return false;
return SD.rename(temp, path);
}
bool archivePacket(const String &name) {
String from = "/queue/" + name, to = "/sent/" + name;
if (!SD.exists(to)) return SD.rename(from, to);
String a, b;
return readPacket(from, a) && readPacket(to, b) && a == b && SD.remove(from);
}
bool upload(const String &body) {
if (!PPP.hasIP() || time(nullptr) < 1735689600 || strlen(SECRET) != 64) return false;
JsonDocument d; if (deserializeJson(d, body)) return false;
String id = d["id"].as<String>(); d["secret"] = SECRET;
String payload; serializeJson(d, payload);
NetworkClientSecure client; client.useBuiltinCACertBundle(); client.setHandshakeTimeout(8);
HTTPClient http; http.setConnectTimeout(5000); http.setTimeout(8000);
http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
const char *headers[] = {"Location"}; http.collectHeaders(headers, 1);
if (!String(SHEET_URL).startsWith("https://script.google.com/macros/s/") || !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) {
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 networkStep() {
if (PPP.hasIP() || uint32_t(millis() - netTry) < 60000) return;
netTry = millis();
if (!modemStarted) {
if (String(APN) == "CHANGE_ME") return;
PPP.setApn(APN); PPP.setPins(26, 27); PPP.sendAtBurst(true);
modemStarted = PPP.begin(PPP_MODEM_SIM800, 1, 115200);
}
if (modemStarted) {
// Проверки в командном режиме. Не посылаем AT в поток PPP.
if (PPP.mode() != ESP_MODEM_MODE_COMMAND && !PPP.mode(ESP_MODEM_MODE_COMMAND)) return;
if (PPP.attached()) { PPP.setDefault(); PPP.mode(ESP_MODEM_MODE_DATA); }
}
}
void setup() {
Serial.begin(115200); setenv("TZ", "UTC0", 1); tzset();
WiFi.mode(WIFI_STA); uint8_t nonce[16]; esp_fill_random(nonce, sizeof(nonce)); WiFi.mode(WIFI_OFF);
char pair[3]; for (uint8_t b : nonce) { snprintf(pair, sizeof(pair), "%02x", b); boot += pair; }
samples = xQueueCreate(16, sizeof(Point)); if (!samples) { Serial.println("RAM queue failed"); return; }
gpsSerial.setRxBufferSize(4096); gpsSerial.begin(9600, SERIAL_8N1, 16, 17);
SPI.begin(18, 19, 23, SD_CS);
storageOK = SD.begin(SD_CS, SPI, 1000000) &&
(SD.exists("/queue") || SD.mkdir("/queue")) && (SD.exists("/sent") || SD.mkdir("/sent"));
String first; if (storageOK) storageOK = pendingCount(first) >= 0;
if (xTaskCreate(captureTask, "gps", 6144, nullptr, 1, nullptr) != pdPASS) {
storageOK = false; Serial.println("GPS task failed"); return;
}
configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
netTry = uint32_t(millis() - 60000);
}
void loop() {
if (!samples) { delay(1000); return; }
Point p;
if (storageOK && xQueueReceive(samples, &p, 0) == pdTRUE) {
String first; int count = pendingCount(first);
if (count >= MAX_PENDING) lost.fetch_add(1); // Сохраняем старую очередь, продолжаем её отправку.
else if (count < 0 || !savePoint(p)) { storageOK = false; lost.fetch_add(1); }
}
if (storageOK) {
networkStep();
if (PPP.hasIP() && uint32_t(millis() - sendTry) >= 10000) {
sendTry = millis(); String name; int count = pendingCount(name);
if (count < 0) storageOK = false;
else if (count) {
String body;
if (!readPacket("/queue/" + name, body)) storageOK = false;
else if (upload(body) && !archivePacket(name)) storageOK = false;
}
}
}
if (uint32_t(millis() - statusAt) >= 10000) {
statusAt = millis(); Serial.printf("storage=%d RAM=%u lost=%lu\n", storageOK, unsigned(uxQueueMessagesWaiting(samples)), (unsigned long)lost.load());
}
delay(10);
}
Читаем маршрут по времени измерения
Раз в минуту появляется запись с номером и идентификатором запуска. GPS читается отдельной задачей, пока сеть занята отправкой. Если свежей пригодной фиксации нет, в таблице появится no_fix с пустыми координатами — полезная отметка разрыва наблюдений. moving и low_speed показывают сравнение скорости с порогом.
Параметры отбора здесь выбраны автором для примера: возраст полей до 1,5 с, HDOP не выше 2,5, от четырёх спутников, скорость не выше 120 км/ч. Они не гарантируют заданную точность. Расстояние накапливается между последовательными принятыми фиксациями с разрывом до 5 с и скоростью выше 3 км/ч в обеих точках. Слишком большой скачок отбрасывается. Медленное движение и интервалы без фиксации пропускаются; сумма km_since_boot сбрасывается при перезапуске. Не используйте её вместо одометра.
Для карты возьмите valid=true и сортируйте по fix_utc — времени измерения GPS. received_utc поможет увидеть, когда запись дошла до сервера. После восстановления связи строки могут прийти в другом порядке. Разделяйте разные запуски и разрывы, затем сравните результат с известным маршрутом и стоянкой.
Что происходит, когда сотовая связь пропадает
Перед отправкой запись попадает на microSD: программа записывает временный файл с CRC32, перечитывает его и переименовывает. После подтверждения файл переходит из queue в sent. Понаблюдайте за этим перемещением. Архив sent остаётся на карте и автоматически не очищается; ограничьте доступ к открытым координатам. CRC32 служит проверкой случайного повреждения.
В queue помещается до 120 записей, в оперативной памяти — ещё 16. При заполнении дисковой очереди новые записи отбрасываются, а отправка старых продолжается; счётчик lost увеличивается. Ошибка карты, повреждённая запись или незавершённый файл .tmp останавливают запись и передачу до разбирательства и перезапуска. Откройте монитор последовательного порта в Arduino IDE, задайте скорость 115200 бод и следите за показателями storage, RAM и lost. Сначала сохраните карту и установите причину ошибки; не удаляйте очередь наугад.
Если ответ сервера потерялся, трекер повторяет тот же id. При сохранённом журнале сервер подтверждает его без второй строки, а другие данные с таким id отклоняет. Проверьте это при кратком обрыве. Карта, питание и переполнение остаются отдельными причинами потерь; зависший модем может потребовать ручного перезапуска. Автономное питание и аппаратное восстановление можно развивать следующим этапом.
Пробуем сообщение о положении относительно участка
Сначала добейтесь устойчивой записи в таблицу. Затем создайте бота через BotFather, начните с ним диалог и получите chat_id своего чата через getUpdates по Telegram Bot API. Сохраните TG_TOKEN и TG_CHAT в свойствах скрипта. В редакторе Apps Script один раз запустите installAlertTrigger и предоставьте запрошенные разрешения: он создаёт минутный триггер flushAlerts. Не делайте вызов Telegram частью приёма каждой GPS-точки.
Для первого опыта задайте в FENCE прямоугольник своего участка: enabled:true, latMin, latMax, lngMin, lngMax и margin. Последний параметр задаёт полосу неопределённости в градусах — подберите её по разбросу координат. Ширина и высота должны превышать удвоенный margin. Для повторения сложной границы поля потребуется другой алгоритм.
Пройдите границу и посмотрите, когда состояние изменится. Нужны две свежие точки с интервалом хотя бы 30 секунд и отставанием от сервера до 120 секунд. Полоса у границы сбрасывает подтверждение, а no_fix отменяет ожидающее уведомление и начинает проверку заново. Старые или переставленные точки тревогу не создают. После разрыва сообщение описывает наблюдаемое положение вне зоны.
Триггер удаляет ожидающее сообщение только после HTTP 200 и ok:true от Telegram. Ошибка оставляет его до следующей попытки, но через 120 с событие устаревает и отменяется. Хранится последнее подтверждённое изменение, а не вся история оповещений; неизвестный результат отправки может привести к повтору. Успех API не означает, что человек прочитал сообщение. Отдельной тревоги о пропаже трекера здесь нет — для охранной задачи нужен независимый контроль связи и план действий.
Первый маршрут и несколько проверок связи
На стенде проверьте питание при передаче, запуск модема, получение свежих координат, появление одной строки в таблице и переход файла в sent. Отключите связь, накопите записи, верните сеть и проверьте отсутствие дублей. Проверьте неверный секрет, отсутствие GPS, заполненную очередь, извлечённую карту и перезапуск с незавершённой записью. Испытания с отключением питания проводите на отдельной карте с сохранённой копией. Затем сравните журнал с известным маршрутом, проверьте стоянку и границу геозоны без подключения исполнительных устройств.
Сохраните результаты первого маршрута вместе с заметками: где пропали координаты, когда пришли отложенные строки, как вела себя геозона. Эти наблюдения помогут выбрать место антенн и подготовить установку на машину.
Связываем маршрут с работой хозяйства
Обновление по воздуху не включено в этот трекер. Для него нужны подписанная прошивка, подходящие разделы памяти и проверка восстановления после сбоя; подход разобран в отдельной статье об OTA. Перенос обновления на сотовое соединение требует своих испытаний и учёта трафика. Смартфон с записью GPS пригодится для предварительного сравнения маршрута.
Для связи трека с работами и взвешиваниями нужны идентификаторы техники и время измерения. На сайте «ТерраКвант» описаны инструменты учёта и подключения оборудования; совместимость конкретного самодельного трекера нужно согласовать и проверить отдельно. Начните с одной машины и одного понятного способа использовать маршрут. Заранее определите, кто будет смотреть журнал и какие решения принимать по нему.





