Перевод статьи IoT Parcel Tracking System: Protect Every Package LiveVedhathiri, Circuit Digest.

Представь, что ты только что отправил посылку курьерской службой. Как только она ушла из рук, начинаются вопросы: с ней аккуратно обращаются? её не уронили, ничего не повредили? она вообще едет по нужному маршруту? дойдёт ли целой и в срок? Правда в том, что после отправки мы почти ничего не знаем о том, что происходит с посылкой в пути. Остаётся только ждать и надеяться, что она приедет в целости.

Но при нынешних технологиях — обязательно ли полагаться на одну надежду? А если бы можно было в любой момент узнать, где посылка, выяснить, обращались ли с ней грубо, и получить уведомление, если что-то пошло не так? Именно на этой идее и построена система трекинга посылок в реальном времени.

В этом проекте мы собрали умную систему трекинга на плате GeoLinker: она непрерывно отслеживает местоположение посылки, ловит удары и вибрации встроенным акселерометром, выгружает данные в облако и мгновенно уведомляет владельца по SMS, как только зафиксирован сильный удар. Перед тем как начать, потрать пару минут на вики-страницу GeoLinker — так будет понятнее, что за плата, какие у неё возможности и характеристики.

Что понадобится#

Компонентов нужно совсем немного. Ниже — то, без чего систему мониторинга посылки не собрать.

КомпонентЗачем нуженЗамена
1Плата GeoLinkerGeoLinker GL868_ESP32 — готовая к серийному применению плата с открытым исходным кодом, объединяющая ESP32-S3 и GSM-модем SIM868; используется для задач трекинга
2Nano SIM (IoT или обычная)Рекомендуется IoT-SIM (при покупке платы даётся 3 месяца бесплатного трафика). Подойдёт любая SIM с поддержкой 2GОбычная 2G SIM
3Li-Ion / LiPo аккумулятор 3,7 ВПитание платы и тесты
4Кабель USB-CПрошивка, отладка и зарядка

Схема подключения#

Собирать почти нечего: для работы нужны только плата GeoLinker и аккумулятор. Корпус опционален — он нужен лишь для защиты и приличного внешнего вида. Как только аккумулятор подключён к плате, система автоматически включается и начинает инициализацию.

Плата 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-соединение падает после отправки SMSGSM-модем не смог заново подключиться к сети GPRS после SMSПроверь процедуру переподключения GPRS и убедись, что модем восстанавливает связь до попытки выгрузки в облако

Демонстрация работы#

На изображении ниже — SMS-оповещения, которые формирует система. Первое сообщение уведомляет о зафиксированном ударе: величина удара, счётчик, длительность поездки и заряд. Второе — сводный отчёт за 30 минут: обзор состояния посылки за период мониторинга, включая общее число ударов и прочую информацию.

SMS с оповещением о сильном ударе и сводным отчётом по доставке

SEVERE IMPACT ALERT с величиной удара 4059 mg при пороге 4000 и сводка DELIVERY SUMMARY.

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

Дашборд трекинга посылки с данными точки маршрута в облаке CircuitDigest

Точка маршрута в дашборде: координаты, скорость, число спутников и 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 минут?

Она даёт периодический отчёт: статистика ударов, состояние аккумулятора, длительность поездки и текущее местоположение груза.