Умный трекинг посылок на IoT: GPS, детектор удара и SMS
Перевод статьи IoT Parcel Tracking System: Protect Every Package Live — Vedhathiri, Circuit Digest.
Представь, что ты только что отправил посылку курьерской службой. Как только она ушла из рук, начинаются вопросы: с ней аккуратно обращаются? её не уронили, ничего не повредили? она вообще едет по нужному маршруту? дойдёт ли целой и в срок? Правда в том, что после отправки мы почти ничего не знаем о том, что происходит с посылкой в пути. Остаётся только ждать и надеяться, что она приедет в целости.
Но при нынешних технологиях — обязательно ли полагаться на одну надежду? А если бы можно было в любой момент узнать, где посылка, выяснить, обращались ли с ней грубо, и получить уведомление, если что-то пошло не так? Именно на этой идее и построена система трекинга посылок в реальном времени.
В этом проекте мы собрали умную систему трекинга на плате GeoLinker: она непрерывно отслеживает местоположение посылки, ловит удары и вибрации встроенным акселерометром, выгружает данные в облако и мгновенно уведомляет владельца по SMS, как только зафиксирован сильный удар. Перед тем как начать, потрать пару минут на вики-страницу GeoLinker — так будет понятнее, что за плата, какие у неё возможности и характеристики.
Что понадобится#
Компонентов нужно совсем немного. Ниже — то, без чего систему мониторинга посылки не собрать.
| № | Компонент | Зачем нужен | Замена |
|---|---|---|---|
| 1 | Плата GeoLinker | GeoLinker GL868_ESP32 — готовая к серийному применению плата с открытым исходным кодом, объединяющая ESP32-S3 и GSM-модем SIM868; используется для задач трекинга | — |
| 2 | Nano SIM (IoT или обычная) | Рекомендуется IoT-SIM (при покупке платы даётся 3 месяца бесплатного трафика). Подойдёт любая SIM с поддержкой 2G | Обычная 2G SIM |
| 3 | Li-Ion / LiPo аккумулятор 3,7 В | Питание платы и тесты | — |
| 4 | Кабель USB-C | Прошивка, отладка и зарядка | — |
Схема подключения#
Собирать почти нечего: для работы нужны только плата GeoLinker и аккумулятор. Корпус опционален — он нужен лишь для защиты и приличного внешнего вида. Как только аккумулятор подключён к плате, система автоматически включается и начинает инициализацию.

Всё железо системы: плата GeoLinker и аккумулятор 3,7 В.
Как работает система мониторинга посылки#
Сбор данных и детект удара#
После включения ESP32 инициализирует плату GeoLinker, модуль GPS, связь GSM/GPRS, систему контроля заряда и встроенный акселерометр LIS3DHTR. Акселерометр выполняет калибровку базового уровня и непрерывно измеряет ускорение по всем трём осям. Как только измеренное ускорение превышает заданный порог, система фиксирует удар, классифицирует его как вибрацию, рывок или подскок, обновляет счётчики и записывает событие — это и есть основа того, как проект распознаёт грубое обращение с грузом.
Определение местоположения и оповещения#
Модуль GPS непрерывно получает координаты посылки, что и даёт живой трекинг на всём протяжении перевозки. Если зафиксированный удар превышает порог сильного удара, система немедленно формирует SMS с величиной удара, состоянием аккумулятора, длительностью поездки и координатами GPS. Так критичные случаи грубого обращения сразу попадают к зарегистрированному пользователю.
Облачный мониторинг и логирование#
Каждые 30 секунд система собирает JSON-пакет, в котором:
- координаты GPS
- общее число ударов
- пиковое значение удара
- число вибраций
- число рывков
- число подскоков
- длительность поездки
- процент заряда аккумулятора
Эти данные выгружаются в облако CircuitDigest через GPRS для мониторинга и анализа в реальном времени. Кроме того, каждые 30 минут автоматически уходит сводка по SMS: статистика по грузу, состояние аккумулятора, длительность поездки и информация о местоположении.
Разбор кода#
Код начинается с подключения всех необходимых библиотек для GPS, GSM/GPRS, SMS и работы с датчиком. Затем задаются параметры проекта: идентификатор устройства, ключ API, номер для оповещений и пороги ударов. После этого инициализируется плата GeoLinker и устанавливается связь с модулями GPS и GSM. Наконец, поднимаются GPRS-соединение, SMS-сервис и встроенные датчики — система готова к трекингу, обмену с облаком и отправке оповещений.
// ============================================================================
// Configuration
// ============================================================================
#define DEVICE_ID "YourdeviceID"
#define API_KEY "YourAPIKey"
#define ALERT_NUMBER "YourNumber"
// Impact detection
#define IMPACT_THRESHOLD_MG 600.0f // mg — minimum to count as an impact at all
#define IMPACT_COOLDOWN_MS 500UL // min ms between counted impacts (debounce)
// Impact classification bands (mg)
#define BAND_VIBRATION_MAX 1200.0f // < 1200mg -> vibration
#define BAND_JERK_MAX 2000.0f // 1200mg - 2000mg -> jerk
// > BAND_JERK_MAX -> jump / severe
// Severe single-impact alert
#define SEVERE_IMPACT_MG 4000.0f // one hit above this fires the alert
// (alert fires once per session, on the first qualifying impact)
// Recurring summary
#define SUMMARY_INTERVAL_MS (30UL * 60UL * 1000UL) // 30 minutes
// GPS / Cloud
#define GPS_POLL_INTERVAL 5000UL // ms between non-blocking GPS polls
#define CLOUD_SEND_INTERVAL 30 // seconds between cloud pushes
Здесь заданы все ключевые параметры. Идентификатор устройства и ключ API нужны для связи с облачной платформой GeoLinker, номер для оповещений — для SMS. Пороги определяют, какое ускорение вообще считать ударом (IMPACT_THRESHOLD_MG, 600 mg) и с какого значения удар считается сильным (SEVERE_IMPACT_MG, 4000 mg). Отдельно стоит отметить IMPACT_COOLDOWN_MS — это антидребезг: без него один физический удар насчитал бы десятки событий подряд.
// ============================================================================
// LIS3DHTR — Direct I2C registers (no extra library needed)
// GeoLinker has LIS3DHTR built in at 0x19
// ============================================================================
#define LIS3DH_CTRL_REG1 0x20
#define LIS3DH_CTRL_REG4 0x23
#define LIS3DH_OUT_X_L 0x28
static bool accelInit() {
Wire.begin();
Wire.beginTransmission(LIS3DH_ADDR);
// CTRL_REG1: ODR=100Hz, all axes enabled (0x57)
Wire.write(LIS3DH_CTRL_REG1);
Wire.write(0x57);
if (Wire.endTransmission() != 0) {
Serial.println("[ACCEL] LIS3DHTR not found at 0x19 — check I2C");
return false;
}
Wire.beginTransmission(LIS3DH_ADDR);
// CTRL_REG4: ±4g range, high-res (0x10)
Wire.write(LIS3DH_CTRL_REG4);
Wire.write(0x10);
Wire.endTransmission();
Serial.println("[ACCEL] LIS3DHTR initialised (±4g, 100Hz)");
return true;
}
Эта часть отвечает за детект ударов и вибраций. Акселерометр LIS3DHTR настраивается напрямую через регистры по I2C — отдельная библиотека не нужна, датчик уже стоит на плате GeoLinker по адресу 0x19. CTRL_REG1 включает все три оси с частотой 100 Гц, CTRL_REG4 задаёт диапазон ±4g и режим высокого разрешения. После инициализации система вычисляет динамическое ускорение, вычитая влияние гравитации по калибровке базового уровня.
// ============================================================================
// Impact classification helpers (ImpactBand enum itself is defined earlier,
// before accelInit/accelRead, so Arduino's auto-prototyping can see it)
// ============================================================================
static ImpactBand classifyImpact(float mg) {
if (mg < BAND_VIBRATION_MAX) return BAND_VIBRATION;
if (mg <= BAND_JERK_MAX) return BAND_JERK;
return BAND_JUMP;
}
static const char* bandName(ImpactBand b) {
switch (b) {
case BAND_VIBRATION: return "vibration";
case BAND_JERK: return "jerk";
case BAND_JUMP: return "jump";
}
return "?";
}
Как только удар зафиксирован, он попадает в одну из категорий — вибрация, рывок или подскок — в зависимости от величины. Такая классификация помогает понять, насколько грубыми были условия перевозки. Границы диапазонов заданы в конфигурации: до 1200 mg — вибрация, до 2000 mg — рывок, выше — подскок.
// ── STEP 1: Read accelerometer every loop tick (fast) ─────────────────────
float ax, ay, az;
if (accelRead(&ax, &ay, &az)) {
float dynamicMg = accelMagnitude(ax, ay, az);
// Impact detected
if (dynamicMg > IMPACT_THRESHOLD_MG) {
uint32_t now = millis();
// Debounce — only count one impact per IMPACT_COOLDOWN_MS
if (now - lastImpactTime > IMPACT_COOLDOWN_MS) {
lastImpactTime = now;
impactCount++;
impactsSinceLastPush++;
ImpactBand band = classifyImpact(dynamicMg);
bandCount[band]++;
// Add to RAM ring buffer (NOT written to NVS here — batched flush instead)
impactLogPush(now, dynamicMg, band);
if (dynamicMg > peakImpact) peakImpact = dynamicMg;
Serial.printf("[IMPACT] #%lu %.0f mg (%s) (peak: %.0f mg)\n",
impactCount, dynamicMg, bandName(band), peakImpact);
// Severe single-impact — set a DEFERRED flag instead of sending SMS
// inline. The actual SMS send happens below in the GPS-poll section,
// keeping this fast path free from 30s+ modem blocking.
if (dynamicMg > SEVERE_IMPACT_MG && !severeAlertSent) {
severeAlertSent = true;
pendingSevereAlert = true;
pendingSevereMg = dynamicMg;
Serial.println("[IMPACT] SEVERE — alert queued for next poll tick.");
}
// Flag an immediate cloud push so the impact shows up without waiting
// for the 30s interval. Actual push happens in the GPS-poll section.
pendingImpactPush = true;
}
}
Это ядро проекта — быстрый путь, который выполняется на каждом витке цикла. Здесь же видна деталь, которой нет в исходном разборе статьи: при сильном ударе SMS не отправляется сразу на месте. Вместо этого выставляется флаг pendingSevereAlert, а собственно отправка происходит позже, в секции опроса GPS. Причина в комментарии автора: работа с модемом блокирует выполнение на 30 с и больше, и держать это в быстром пути нельзя. Само оповещение о сильном ударе срабатывает один раз за сессию — за это отвечает флаг severeAlertSent.
// Battery is already part of the core GPS data via addDataPoint()
// Impact metrics + band breakdown go as custom payload fields
GeoLinker.setPayloads({
{ "impacts", (float)impactCount },
{ "peak_mg", peakImpact },
{ "new_impacts", (float)impactsSinceLastPush },
{ "ride_min", (float)rideMinutes },
{ "vib_count", (float)bandCount[BAND_VIBRATION] },
{ "jerk_count", (float)bandCount[BAND_JERK] },
{ "jump_count", (float)bandCount[BAND_JUMP] }
});
GeoLinker.json.clear();
GeoLinker.json.addDataPoint(*gps,
GeoLinker.getBatteryPercent(),
GeoLinker.getSignalStrength());
char payload[1024];
if (!GeoLinker.json.build(payload, sizeof(payload))) {
Serial.println("[CLOUD] JSON build failed.");
GeoLinker.clearPayloads();
return;
}
Serial.printf("[CLOUD] Push: %.6f,%.6f Impacts:%lu Peak:%.0fmg Bat:%d%%\n",
gps->latitude, gps->longitude,
impactCount, peakImpact,
GeoLinker.getBatteryPercent());
int code = GeoLinker.gsm.httpPOST(
"http://www.circuitdigest.cloud/api/v1/geolinker",
API_KEY, "application/json", payload);
Эта часть отвечает за связь с облаком и хранение данных. Каждые 30 секунд формируется JSON-пакет с координатами, статистикой ударов, процентом заряда, длительностью поездки и разбивкой по категориям. Данные уходят на платформу CircuitDigest через GPRS-соединение, что и даёт удалённый мониторинг.
Что делать, если не работает#
Ниже — проблемы, которые вылезли при сборке системы, и способы, которыми их удалось решить. Посмотри на них, если столкнёшься с чем-то похожим.
| Проблема | Возможная причина | Решение |
|---|---|---|
| Удары не фиксируются | Акселерометр LIS3DHTR не отвечает по I2C, либо порог удара выставлен слишком высоко для реальных вибраций | Убедись, что акселерометр успешно инициализируется при старте, и посмотри сырые значения ускорения в Serial Monitor |
| Местоположение GPS не обновляется | У модуля GPS нет открытого неба, приём со спутников слабый | Вынеси устройство на улицу, на открытое место, и дай ему время поймать спутники до начала мониторинга |
| Координаты GPS неточные | Переотражения сигнала, препятствия или мало спутников | Работай на открытом месте и дождись, пока подключится больше спутников, прежде чем доверять координатам |
| Устройство неожиданно перезагружается | Скачки питания, низкое напряжение аккумулятора или слишком большой ток потребления | Используй стабильный источник питания, аккумулятор достаточной ёмкости и проверь все силовые соединения |
| Процент заряда показывается неверно | Настройки калибровки аккумулятора не соответствуют его реальным характеристикам | Проверь измерение напряжения аккумулятора и при необходимости поправь конфигурацию диапазона |
| GPRS-соединение падает после отправки SMS | GSM-модем не смог заново подключиться к сети GPRS после SMS | Проверь процедуру переподключения GPRS и убедись, что модем восстанавливает связь до попытки выгрузки в облако |
Демонстрация работы#
На изображении ниже — SMS-оповещения, которые формирует система. Первое сообщение уведомляет о зафиксированном ударе: величина удара, счётчик, длительность поездки и заряд. Второе — сводный отчёт за 30 минут: обзор состояния посылки за период мониторинга, включая общее число ударов и прочую информацию.

SEVERE IMPACT ALERT с величиной удара 4059 mg при пороге 4000 и сводка DELIVERY SUMMARY.
Здесь показан живой трекинг посылки. Система выгружает данные в облако каждые 30 секунд, так что текущее местоположение груза видно в реальном времени. Кроме того, для каждой точки маршрута записываются и отображаются вибрации, рывки, подскоки и сильные удары — получается полная картина того, как с посылкой обращались в пути.

Точка маршрута в дашборде: координаты, скорость, число спутников и payload со счётчиками ударов.
На этом система мониторинга посылки готова. Проект показывает, как на плате GeoLinker GL868 ESP32 собрать надёжное решение для трекинга груза по GPS, распознавания грубого обращения и получения обновлений в реальном времени через облако и SMS. Хотя этот прототип сделан именно под посылки, ту же идею можно расширить на логистику, управление парком, учёт имущества и множество других задач.
Живая демонстрация#
Как система работает в реальных условиях, можно посмотреть на видео: Secure Smart Parcel System Using IoT.
Полный код#
Полный скетч, документация и файлы проекта лежат в репозитории на GitHub. Ниже — полный листинг оттуда, чтобы всё было под рукой.
#include <GL868_ESP32.h>
#include <Preferences.h>
#include <Wire.h>
// ============================================================================
// Configuration
// ============================================================================
#define DEVICE_ID "YourdeviceID"
#define API_KEY "YourAPIKey"
#define ALERT_NUMBER "YourNumber"
// Impact detection
#define IMPACT_THRESHOLD_MG 600.0f // mg — minimum to count as an impact at all
#define IMPACT_COOLDOWN_MS 500UL // min ms between counted impacts (debounce)
// Impact classification bands (mg)
#define BAND_VIBRATION_MAX 1200.0f // < 1200mg -> vibration
#define BAND_JERK_MAX 2000.0f // 1200mg - 2000mg -> jerk
// > BAND_JERK_MAX -> jump / severe
// Severe single-impact alert
#define SEVERE_IMPACT_MG 4000.0f // one hit above this fires the alert
// (alert fires once per session, on the first qualifying impact)
// Recurring summary
#define SUMMARY_INTERVAL_MS (30UL * 60UL * 1000UL) // 30 minutes
// GPS / Cloud
#define GPS_POLL_INTERVAL 5000UL // ms between non-blocking GPS polls
#define CLOUD_SEND_INTERVAL 30 // seconds between cloud pushes
#define GPS_LOSS_ALERT_POLLS 5
// Timing
#define TIME_OFFSET_HOURS 5
#define TIME_OFFSET_MINS 30
#define CIPSHUT_SETTLE_MS 2000UL
// Accelerometer — LIS3DHTR I2C address on GeoLinker board
#define LIS3DH_ADDR 0x19
#define GRAVITY_MG 1000.0f // 1g = 1000mg
// Impact log (RAM ring buffer, periodically flushed to NVS — see note above)
#define IMPACT_LOG_SIZE 200 // most recent impacts kept
#define IMPACT_LOG_FLUSH_MS 60000UL // flush ring buffer to NVS this often
// Watchdog heartbeat interval
#define WATCHDOG_INTERVAL_MS 120000UL // 2 minutes
// ============================================================================
// Impact classification
// NOTE: this enum must be defined before any function that uses it as a
// parameter or return type. The Arduino IDE auto-generates forward
// declarations for every function near the top of the file (before your
// code runs), so if a function signature references a type that's defined
// further down, the auto-prototype fails with "does not name a type" —
// even though the actual function definition further down would have been
// fine on its own. Keeping this enum here, ahead of accelInit/accelRead/
// classifyImpact/bandName/impactLogPush, avoids that ordering problem.
// ============================================================================
enum ImpactBand : uint8_t {
BAND_VIBRATION = 0,
BAND_JERK = 1,
BAND_JUMP = 2
};
// ============================================================================
// LIS3DHTR — Direct I2C registers (no extra library needed)
// GeoLinker has LIS3DHTR built in at 0x19
// ============================================================================
#define LIS3DH_CTRL_REG1 0x20
#define LIS3DH_CTRL_REG4 0x23
#define LIS3DH_OUT_X_L 0x28
static bool accelInit() {
Wire.begin();
Wire.beginTransmission(LIS3DH_ADDR);
// CTRL_REG1: ODR=100Hz, all axes enabled (0x57)
Wire.write(LIS3DH_CTRL_REG1);
Wire.write(0x57);
if (Wire.endTransmission() != 0) {
Serial.println("[ACCEL] LIS3DHTR not found at 0x19 — check I2C");
return false;
}
Wire.beginTransmission(LIS3DH_ADDR);
// CTRL_REG4: ±4g range, high-res (0x10)
Wire.write(LIS3DH_CTRL_REG4);
Wire.write(0x10);
Wire.endTransmission();
Serial.println("[ACCEL] LIS3DHTR initialised (±4g, 100Hz)");
return true;
}
// Returns true if a new sample is read
static bool accelRead(float *ax, float *ay, float *az) {
Wire.beginTransmission(LIS3DH_ADDR);
Wire.write(0x80 | LIS3DH_OUT_X_L); // auto-increment bit
if (Wire.endTransmission(false) != 0) return false;
Wire.requestFrom((uint8_t)LIS3DH_ADDR, (uint8_t)6);
if (Wire.available() < 6) return false;
int16_t rx = (int16_t)((Wire.read()) | (Wire.read() << 8));
int16_t ry = (int16_t)((Wire.read()) | (Wire.read() << 8));
int16_t rz = (int16_t)((Wire.read()) | (Wire.read() << 8));
// ±4g range, 12-bit left-justified → shift right 4, scale by 2mg/LSB
*ax = (rx >> 4) * 2.0f; // mg
*ay = (ry >> 4) * 2.0f;
*az = (rz >> 4) * 2.0f;
return true;
}
// Calibration baseline — measured at rest on first reads
static float baseAx = 0, baseAy = 0, baseAz = 0;
static bool baseCalibrated = false;
static void calibrateBaseline() {
// Average 20 readings at rest to get gravity vector in any orientation
float sumX = 0, sumY = 0, sumZ = 0;
int samples = 0;
Serial.println("[ACCEL] Calibrating baseline (keep still for 1s)...");
uint32_t t = millis();
while (millis() - t < 1000) {
float x, y, z;
if (accelRead(&x, &y, &z)) {
sumX += x; sumY += y; sumZ += z;
samples++;
}
delay(50);
}
if (samples > 0) {
baseAx = sumX / samples;
baseAy = sumY / samples;
baseAz = sumZ / samples;
baseCalibrated = true;
Serial.printf("[ACCEL] Baseline: X=%.1f Y=%.1f Z=%.1f mg (%d samples)\n",
baseAx, baseAy, baseAz, samples);
}
}
// Returns dynamic acceleration in mg with orientation-independent gravity removal
static float accelMagnitude(float ax, float ay, float az) {
if (!baseCalibrated) return 0.0f;
// Subtract the per-axis baseline (removes gravity in any orientation)
float dx = ax - baseAx;
float dy = ay - baseAy;
float dz = az - baseAz;
return sqrtf(dx*dx + dy*dy + dz*dz);
}
// ============================================================================
// Impact classification helpers (ImpactBand enum itself is defined earlier,
// before accelInit/accelRead, so Arduino's auto-prototyping can see it)
// ============================================================================
static ImpactBand classifyImpact(float mg) {
if (mg < BAND_VIBRATION_MAX) return BAND_VIBRATION;
if (mg <= BAND_JERK_MAX) return BAND_JERK;
return BAND_JUMP;
}
static const char* bandName(ImpactBand b) {
switch (b) {
case BAND_VIBRATION: return "vibration";
case BAND_JERK: return "jerk";
case BAND_JUMP: return "jump";
}
return "?";
}
// ============================================================================
// Runtime state
// ============================================================================
static Preferences prefs;
static uint32_t lastPollTime = 0;
static uint32_t lastCloudPush = 0;
static uint32_t lastImpactTime = 0;
static int gpsFailStreak = 0;
static bool gpsLossAlertSent = false;
static bool gprsAttached = false;
// Impact tracking
static uint32_t impactCount = 0; // total this session
static uint32_t impactCountSaved = 0; // loaded from NVS on boot
static float peakImpact = 0.0f; // highest mg seen
static uint32_t impactsSinceLastPush = 0; // new impacts since last cloud push
// Per-band counters (session totals, used in summaries + cloud payload)
static uint32_t bandCount[3] = {0, 0, 0}; // vibration, jerk, jump
// Severe single-impact alert — fires once per session
static bool severeAlertSent = false;
// Deferred severe-alert flag (set in accel path, consumed in GPS-poll section)
static bool pendingSevereAlert = false;
static float pendingSevereMg = 0.0f;
// Deferred immediate cloud push flag (set on impact, consumed in GPS-poll section)
static bool pendingImpactPush = false;
// Recurring 30-min summary
static uint32_t lastSummaryMs = 0;
static uint32_t summaryImpactBase = 0; // impactCount at last summary
static float summaryPeakBase = 0.0f; // peak reset point for "this period"
static uint32_t summaryBandBase[3] = {0, 0, 0};
// Session tracking (for battery life test)
static uint32_t sessionStartMs = 0;
static uint8_t startBattery = 0;
static GPSData lastKnownGPS = {};
static bool hasEverHadFix = false;
// Watchdog heartbeat
static uint32_t lastWatchdogMs = 0;
// ============================================================================
// Impact log — RAM ring buffer, flushed to NVS periodically (see header note)
// ============================================================================
struct ImpactLogEntry {
uint32_t t_ms; // millis() timestamp of the impact
float mg; // dynamic acceleration magnitude
uint8_t band; // ImpactBand
};
static ImpactLogEntry impactLog[IMPACT_LOG_SIZE];
static uint16_t impactLogHead = 0; // next write index (wraps)
static uint16_t impactLogCount = 0; // entries used (caps at IMPACT_LOG_SIZE)
static uint32_t lastLogFlushMs = 0;
static void impactLogPush(uint32_t t_ms, float mg, ImpactBand band) {
impactLog[impactLogHead].t_ms = t_ms;
impactLog[impactLogHead].mg = mg;
impactLog[impactLogHead].band = (uint8_t)band;
impactLogHead = (impactLogHead + 1) % IMPACT_LOG_SIZE;
if (impactLogCount < IMPACT_LOG_SIZE) impactLogCount++;
}
// Flush the ring buffer to NVS as a single binary blob.
// Batched/periodic by design — NOT called per-impact. See header note on flash wear.
static void impactLogFlush() {
prefs.begin("fragile_log", false);
prefs.putBytes("log_blob", impactLog, sizeof(impactLog));
prefs.putUShort("log_head", impactLogHead);
prefs.putUShort("log_count", impactLogCount);
prefs.end();
Serial.printf("[LOG] Flushed %u impact log entries to NVS.\n", impactLogCount);
}
static void impactLogLoad() {
prefs.begin("fragile_log", true);
size_t got = prefs.getBytes("log_blob", impactLog, sizeof(impactLog));
impactLogHead = prefs.getUShort("log_head", 0);
impactLogCount = prefs.getUShort("log_count", 0);
prefs.end();
if (got != sizeof(impactLog)) {
// No previous log or size mismatch — start clean
memset(impactLog, 0, sizeof(impactLog));
impactLogHead = 0;
impactLogCount = 0;
}
Serial.printf("[LOG] Loaded %u prior impact log entries from NVS.\n", impactLogCount);
}
static void impactLogClear() {
prefs.begin("fragile_log", false);
prefs.clear();
prefs.end();
memset(impactLog, 0, sizeof(impactLog));
impactLogHead = 0;
impactLogCount = 0;
}
// ============================================================================
// NVS persistence — saves impact count across power cycles
// ============================================================================
static void loadSession() {
prefs.begin("fragile", true);
impactCountSaved = prefs.getUInt("impacts", 0);
prefs.end();
impactCount = impactCountSaved;
Serial.printf("[NVS] Loaded impact count: %lu\n", impactCount);
}
static void saveSession() {
prefs.begin("fragile", false);
prefs.putUInt("impacts", impactCount);
prefs.end();
}
static void clearSession() {
prefs.begin("fragile", false);
prefs.clear();
prefs.end();
impactCount = 0;
impactCountSaved = 0;
peakImpact = 0.0f;
bandCount[0] = bandCount[1] = bandCount[2] = 0;
Serial.println("[NVS] Session cleared.");
}
// ============================================================================
// GPRS helpers
// ============================================================================
static void gprsDetach() {
if (!gprsAttached) return;
Serial.println("[GPRS] Detaching...");
GeoLinker.sendATCommand("+CIPSHUT", 5000);
gprsAttached = false;
delay(CIPSHUT_SETTLE_MS);
Serial.println("[GPRS] Detached.");
}
static void gprsReattach() {
if (gprsAttached) return;
Serial.println("[GPRS] Reattaching...");
if (GeoLinker.gsm.attachGPRS()) {
gprsAttached = true;
Serial.println("[GPRS] Attached.");
} else {
Serial.println("[GPRS] Failed — will retry next push.");
}
}
// ============================================================================
// Raw SMS
// ============================================================================
static bool sendRawSMS(const char *number, const char *message) {
HardwareSerial &mdm = GeoLinker.getModemSerial();
GeoLinker.sendATCommand("+CMGF=1", 2000);
GeoLinker.sendATCommand("+CSCS=\"GSM\"", 2000);
delay(200);
mdm.printf("AT+CMGS=\"%s\"\r", number);
Serial.printf("[SMS] Sending to %s...\n", number);
uint32_t t = millis();
bool gotPrompt = false;
while (millis() - t < 8000) {
if (mdm.available() && mdm.read() == '>') { gotPrompt = true; break; }
delay(10);
}
if (!gotPrompt) {
Serial.println("[SMS] No prompt — aborting.");
mdm.write(0x1B);
return false;
}
for (const char *p = message; *p; p++) {
if (*p == '\n' && (p == message || *(p-1) != '\r')) mdm.write('\r');
mdm.write(*p);
}
mdm.write(0x1A);
t = millis();
while (millis() - t < 30000) {
if (mdm.available()) {
String line = mdm.readStringUntil('\n');
line.trim();
if (line.startsWith("+CMGS:")) { Serial.println("[SMS] Delivered ✓"); return true; }
if (line.indexOf("ERROR") >= 0) { Serial.printf("[SMS] Error: %s\n", line.c_str()); return false; }
}
delay(10);
}
Serial.println("[SMS] Timeout.");
return false;
}
static bool sendReliableSMS(const char *number, const char *message) {
gprsDetach();
bool ok = sendRawSMS(number, message);
gprsReattach();
return ok;
}
// ============================================================================
// GPS acquisition — NON-BLOCKING single poll
// ============================================================================
// Replaces the old getValidLocation() which had nested retry loops that could
// block for up to 5 × 60s = 300 seconds. This version checks once and returns
// immediately, allowing the accelerometer to keep running every loop tick.
// ============================================================================
static bool pollGPSOnce(GPSData *gps) {
GeoLinker.update();
if (GeoLinker.getLocationNow(gps) && gps->valid) {
Serial.printf("[GPS] Fix: %.6f, %.6f Sats:%d Spd:%.1f km/h\n",
gps->latitude, gps->longitude,
gps->satellites, gps->speed);
return true;
}
return false;
}
// ============================================================================
// Severe single-impact SMS alert (fires once per session)
// ============================================================================
static void sendSevereImpactAlert(float mg, GPSData *gps, bool hasLoc) {
uint32_t rideMinutes = (millis() - sessionStartMs) / 60000UL;
char msg[220];
if (hasLoc) {
snprintf(msg, sizeof(msg),
"SEVERE IMPACT ALERT!\n"
"Device:%s\n"
"Impact:%.0f mg (limit:%.0f)\n"
"Total impacts so far:%lu\n"
"Ride time:%lu min\n"
"Bat:%d%%\n"
"Lat:%.6f Lon:%.6f\n"
"Map:maps.google.com/?q=%.6f,%.6f",
DEVICE_ID,
mg, SEVERE_IMPACT_MG,
impactCount,
rideMinutes,
GeoLinker.getBatteryPercent(),
gps->latitude, gps->longitude,
gps->latitude, gps->longitude);
} else {
snprintf(msg, sizeof(msg),
"SEVERE IMPACT ALERT!\n"
"Device:%s\n"
"Impact:%.0f mg (limit:%.0f)\n"
"Total impacts so far:%lu\n"
"Ride time:%lu min\n"
"Bat:%d%%\n"
"No GPS fix",
DEVICE_ID,
mg, SEVERE_IMPACT_MG,
impactCount,
rideMinutes,
GeoLinker.getBatteryPercent());
}
Serial.println("[ALERT] Sending severe impact SMS...");
sendReliableSMS(ALERT_NUMBER, msg);
}
// ============================================================================
// Recurring 30-minute summary SMS
// ============================================================================
static void sendPeriodicSummary(GPSData *gps, bool hasLoc) {
uint32_t rideMinutes = (millis() - sessionStartMs) / 60000UL;
uint32_t periodImpacts = impactCount - summaryImpactBase;
uint32_t periodVib = bandCount[BAND_VIBRATION] - summaryBandBase[BAND_VIBRATION];
uint32_t periodJerk = bandCount[BAND_JERK] - summaryBandBase[BAND_JERK];
uint32_t periodJump = bandCount[BAND_JUMP] - summaryBandBase[BAND_JUMP];
char msg[260];
if (hasLoc) {
snprintf(msg, sizeof(msg),
"30-MIN SUMMARY\n"
"Device:%s\n"
"Ride time:%lu min\n"
"Impacts (last 30m):%lu [vib:%lu jerk:%lu jump:%lu]\n"
"Total impacts:%lu\n"
"Peak:%.0f mg\n"
"Bat:%d%%\n"
"Lat:%.6f Lon:%.6f",
DEVICE_ID,
rideMinutes,
periodImpacts, periodVib, periodJerk, periodJump,
impactCount,
peakImpact,
GeoLinker.getBatteryPercent(),
gps->latitude, gps->longitude);
} else {
snprintf(msg, sizeof(msg),
"30-MIN SUMMARY\n"
"Device:%s\n"
"Ride time:%lu min\n"
"Impacts (last 30m):%lu [vib:%lu jerk:%lu jump:%lu]\n"
"Total impacts:%lu\n"
"Peak:%.0f mg\n"
"Bat:%d%%\n"
"No GPS fix",
DEVICE_ID,
rideMinutes,
periodImpacts, periodVib, periodJerk, periodJump,
impactCount,
peakImpact,
GeoLinker.getBatteryPercent());
}
Serial.println("[SUMMARY] Sending 30-min periodic summary SMS...");
sendReliableSMS(ALERT_NUMBER, msg);
// Reset the "this period" baselines (totals/peak are cumulative, kept as-is)
summaryImpactBase = impactCount;
summaryBandBase[0] = bandCount[0];
summaryBandBase[1] = bandCount[1];
summaryBandBase[2] = bandCount[2];
// Also flush the impact log on every summary, so a durable snapshot
// exists at each 30-min boundary even if power is lost soon after.
impactLogFlush();
}
// ============================================================================
// Final session summary SMS (sent on boot if previous session data found)
// ============================================================================
static void sendSessionSummary(uint32_t savedImpacts) {
char msg[200];
snprintf(msg, sizeof(msg),
"DELIVERY SUMMARY\n"
"Device:%s\n"
"Total impacts:%lu\n"
"Peak force:%.0f mg\n"
"Start battery:%d%%\n"
"End battery:%d%%\n"
"Session saved to cloud.",
DEVICE_ID,
savedImpacts,
peakImpact,
startBattery,
GeoLinker.getBatteryPercent());
Serial.println("[SUMMARY] Sending session summary SMS...");
sendReliableSMS(ALERT_NUMBER, msg);
}
// ============================================================================
// Cloud push — GPS + impact count + battery + band breakdown
// ============================================================================
static void cloudPush(GPSData *gps, bool hasLoc) {
if (!gprsAttached) { gprsReattach(); if (!gprsAttached) return; }
if (!hasLoc) { Serial.println("[CLOUD] Skipping — no GPS fix."); return; }
uint32_t rideMinutes = (millis() - sessionStartMs) / 60000UL;
// Battery is already part of the core GPS data via addDataPoint()
// Impact metrics + band breakdown go as custom payload fields
GeoLinker.setPayloads({
{ "impacts", (float)impactCount },
{ "peak_mg", peakImpact },
{ "new_impacts", (float)impactsSinceLastPush },
{ "ride_min", (float)rideMinutes },
{ "vib_count", (float)bandCount[BAND_VIBRATION] },
{ "jerk_count", (float)bandCount[BAND_JERK] },
{ "jump_count", (float)bandCount[BAND_JUMP] }
});
GeoLinker.json.clear();
GeoLinker.json.addDataPoint(*gps,
GeoLinker.getBatteryPercent(),
GeoLinker.getSignalStrength());
char payload[1024];
if (!GeoLinker.json.build(payload, sizeof(payload))) {
Serial.println("[CLOUD] JSON build failed.");
GeoLinker.clearPayloads();
return;
}
Serial.printf("[CLOUD] Push: %.6f,%.6f Impacts:%lu Peak:%.0fmg Bat:%d%%\n",
gps->latitude, gps->longitude,
impactCount, peakImpact,
GeoLinker.getBatteryPercent());
int code = GeoLinker.gsm.httpPOST(
"http://www.circuitdigest.cloud/api/v1/geolinker",
API_KEY, "application/json", payload);
Serial.printf("[CLOUD] HTTP %d\n", code);
GeoLinker.clearPayloads();
if (code == 200) {
impactsSinceLastPush = 0; // reset delta counter after successful push
} else {
gprsAttached = false; // force reattach next time
}
}
// ============================================================================
// setup()
// ============================================================================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("==============================================");
Serial.println(" Fragile Tracker + Battery Life Test (v2)");
Serial.println(" GL868_ESP32 + LIS3DHTR");
Serial.println(" Non-blocking GPS / deferred alerts");
Serial.println("==============================================");
// Load previous session impact count from NVS
loadSession();
// Only send summary if previous session had meaningful impacts (>5)
bool hadPreviousSession = (impactCountSaved > 5);
// Load prior impact log (raw events) before clearing the counters below.
// Kept separate from clearSession() so a power-cycle doesn't wipe history
// you may still want to retrieve — call impactLogClear() manually if you
// want a hard reset of the raw log too.
impactLogLoad();
// Clear NVS for fresh session (impact counters only, not the raw log)
clearSession();
// Init GeoLinker
GeoLinker.setOperatingMode(MODE_SMS_CALL);
GeoLinker.setTimeOffset(TIME_OFFSET_HOURS, TIME_OFFSET_MINS);
GeoLinker.enableFullPowerOff(false);
GeoLinker.setMotionThreshold(IMPACT_THRESHOLD_MG);
GeoLinker.begin(DEVICE_ID, API_KEY);
Serial.println("[INIT] Waiting for modem IDLE...");
{
uint32_t t = millis();
while (GeoLinker.getState() != STATE_IDLE && millis() - t < 50000UL) {
GeoLinker.update(); delay(100);
}
Serial.printf("[INIT] Modem ready (%lu ms)\n", millis() - t);
}
// Record start battery for session tracking
startBattery = GeoLinker.getBatteryPercent();
Serial.printf("[BATTERY] Start: %d%%\n", startBattery);
// Attach GPRS
Serial.println("[INIT] Attaching GPRS...");
if (GeoLinker.gsm.attachGPRS()) {
gprsAttached = true;
Serial.println("[INIT] GPRS attached.");
} else {
Serial.println("[INIT] GPRS failed — retrying on first push.");
}
// Send previous session summary if applicable
if (hadPreviousSession) {
Serial.printf("[INIT] Previous session had %lu impacts — sending summary.\n",
impactCountSaved);
sendSessionSummary(impactCountSaved);
}
// Init accelerometer
if (!accelInit()) {
Serial.println("[ACCEL] WARNING: Using library motion sensor as fallback.");
GeoLinker.enableMotionTrigger(true);
}
// Wait for the LIS3DHTR output registers to settle after config change.
// Without this, the first calibration samples may read near-zero and the
// baseline will be wrong (gravity won't be properly subtracted).
delay(200);
// Calibrate accelerometer baseline (removes gravity in any orientation)
calibrateBaseline();
GeoLinker.gpsOn();
Serial.println("[GPS] GPS powered on.");
uint32_t now = millis();
sessionStartMs = now;
lastSummaryMs = now;
lastLogFlushMs = now;
lastPollTime = now;
lastCloudPush = now;
lastWatchdogMs = now;
// Print config
Serial.println("\n──────────────────────────────────────────");
Serial.printf("Device ID : %s\n", DEVICE_ID);
Serial.printf("Alert number : %s\n", ALERT_NUMBER);
Serial.printf("Impact threshold : %.0f mg\n", IMPACT_THRESHOLD_MG);
Serial.printf("Severe alert at : >%.0f mg (single hit)\n", SEVERE_IMPACT_MG);
Serial.printf("Summary every : %lu min\n", SUMMARY_INTERVAL_MS / 60000UL);
Serial.printf("Cloud interval : %d s\n", CLOUD_SEND_INTERVAL);
Serial.printf("Start battery : %d%%\n", startBattery);
Serial.println("──────────────────────────────────────────");
Serial.println("Monitoring started. Place in package and send!\n");
}
// ============================================================================
// loop()
// ============================================================================
void loop() {
GeoLinker.update();
// ── WATCHDOG: heartbeat every 2 min so you know the loop is alive ─────────
if (millis() - lastWatchdogMs >= WATCHDOG_INTERVAL_MS) {
lastWatchdogMs = millis();
uint32_t rideMin = (millis() - sessionStartMs) / 60000UL;
Serial.printf("[WDG] Alive — ride %lu min, impacts %lu, bat %d%%\n",
rideMin, impactCount, GeoLinker.getBatteryPercent());
}
// ── STEP 1: Read accelerometer every loop tick (fast) ─────────────────────
float ax, ay, az;
if (accelRead(&ax, &ay, &az)) {
float dynamicMg = accelMagnitude(ax, ay, az);
// Impact detected
if (dynamicMg > IMPACT_THRESHOLD_MG) {
uint32_t now = millis();
// Debounce — only count one impact per IMPACT_COOLDOWN_MS
if (now - lastImpactTime > IMPACT_COOLDOWN_MS) {
lastImpactTime = now;
impactCount++;
impactsSinceLastPush++;
ImpactBand band = classifyImpact(dynamicMg);
bandCount[band]++;
// Add to RAM ring buffer (NOT written to NVS here — batched flush instead)
impactLogPush(now, dynamicMg, band);
if (dynamicMg > peakImpact) peakImpact = dynamicMg;
Serial.printf("[IMPACT] #%lu %.0f mg (%s) (peak: %.0f mg)\n",
impactCount, dynamicMg, bandName(band), peakImpact);
// Severe single-impact — set a DEFERRED flag instead of sending SMS
// inline. The actual SMS send happens below in the GPS-poll section,
// keeping this fast path free from 30s+ modem blocking.
if (dynamicMg > SEVERE_IMPACT_MG && !severeAlertSent) {
severeAlertSent = true;
pendingSevereAlert = true;
pendingSevereMg = dynamicMg;
Serial.println("[IMPACT] SEVERE — alert queued for next poll tick.");
}
// Flag an immediate cloud push so the impact shows up without waiting
// for the 30s interval. Actual push happens in the GPS-poll section.
pendingImpactPush = true;
}
}
}
// ── STEP 2: GPS poll + deferred actions (every GPS_POLL_INTERVAL) ─────────
// Everything below is guarded by the poll interval so it runs at a
// controlled rate (~every 5s) while Step 1 runs every loop tick.
if (millis() - lastPollTime >= GPS_POLL_INTERVAL) {
lastPollTime = millis();
// --- 2a: Non-blocking GPS check ---
GPSData gps = {};
bool hasLoc = pollGPSOnce(&gps);
if (hasLoc) {
if (gpsFailStreak > 0)
Serial.printf("[GPS] Restored after %d failure(s).\n", gpsFailStreak);
gpsFailStreak = 0;
gpsLossAlertSent = false;
lastKnownGPS = gps;
hasEverHadFix = true;
} else {
gpsFailStreak++;
if (gpsFailStreak % 5 == 0) // only log every 5th failure to reduce spam
Serial.printf("[GPS] No fix (streak: %d)\n", gpsFailStreak);
if (gpsFailStreak >= GPS_LOSS_ALERT_POLLS && !gpsLossAlertSent) {
gpsLossAlertSent = true;
Serial.println("[GPS] GPS loss — continuing with last known position.");
}
}
// Resolve the best available GPS for all actions below
GPSData *bestGPS = hasLoc ? &gps : &lastKnownGPS;
bool bestHasLoc = hasLoc || hasEverHadFix;
// --- 2b: Deferred severe-impact SMS ---
if (pendingSevereAlert) {
pendingSevereAlert = false;
sendSevereImpactAlert(pendingSevereMg, bestGPS, bestHasLoc);
}
// --- 2c: Immediate cloud push on impact (deferred from Step 1) ---
if (pendingImpactPush) {
pendingImpactPush = false;
cloudPush(bestGPS, bestHasLoc);
}
// --- 2d: Periodic cloud push every 30s ---
if (millis() - lastCloudPush >= (uint32_t)CLOUD_SEND_INTERVAL * 1000UL) {
lastCloudPush = millis();
cloudPush(bestGPS, bestHasLoc);
}
// --- 2e: 30-minute recurring summary SMS ---
if (millis() - lastSummaryMs >= SUMMARY_INTERVAL_MS) {
lastSummaryMs = millis();
sendPeriodicSummary(bestGPS, bestHasLoc);
}
// --- 2f: Save impact count to NVS periodically (safe here, outside accel read) ---
static uint32_t lastNVSSave = 0;
if (millis() - lastNVSSave > 30000UL) {
lastNVSSave = millis();
saveSession();
}
// --- 2g: Flush impact log ring buffer to NVS periodically ---
if (millis() - lastLogFlushMs > IMPACT_LOG_FLUSH_MS) {
lastLogFlushMs = millis();
impactLogFlush();
}
// --- Status line every poll ---
uint32_t rideMin = (millis() - sessionStartMs) / 60000UL;
Serial.printf("[STATUS] Impacts:%lu [vib:%lu jerk:%lu jump:%lu] Peak:%.0fmg Ride:%lu min Bat:%d%%\n\n",
impactCount,
bandCount[BAND_VIBRATION], bandCount[BAND_JERK], bandCount[BAND_JUMP],
peakImpact, rideMin,
GeoLinker.getBatteryPercent());
}
}
Частые вопросы#
Как система распознаёт удары?
Встроенный акселерометр LIS3DHTR непрерывно измеряет ускорение. Когда измеренное ускорение превышает заданный порог, система считает это событием удара.
Зачем в проекте акселерометр?
Он позволяет обнаруживать толчки, рывки, вибрации и резкие движения, которые могут случиться при перевозке хрупких грузов.
Какой порог удара используется в системе?
Система настроена фиксировать удары выше 600 mg. Любое ускорение выше этого значения считается ударом.
Как классифицируются удары?
По величине измеренного ускорения — на три категории: вибрация, рывок и подскок.
Что считается сильным ударом?
Сильный удар — это одиночный удар свыше 4000 mg. Такие события могут означать грубое обращение или возможное повреждение груза.
Что происходит при сильном ударе?
Система немедленно отправляет SMS на зарегистрированный номер с уведомлением о событии.
Зачем нужна сводка по SMS каждые 30 минут?
Она даёт периодический отчёт: статистика ударов, состояние аккумулятора, длительность поездки и текущее местоположение груза.