Перевод статьи Multi-Geofencing System Using IoT with ESP32 & GPS AlertsVedhathiri, Circuit Digest.

Живой GPS-трекинг знаком всем: он показывает, где машина или устройство находится прямо сейчас. Но что, если система умеет больше, чем просто рисовать точку на карте? Тут-то и появляется геофенсинг.

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

Например, хочется знать, когда машина доехала до универа, уехала с работы, вернулась домой или заехала туда, куда заезжать не следует. Вместо того чтобы весь день пялиться в карту, гораздо удобнее, чтобы система сама сообщала о таких событиях. В этом проекте мы соберём полноценную систему геофенсинга на плате GeoLinker GL868_ESP32 — это ESP32-S3 в паре с GSM-модемом SIM868.

Именно для этого и нужен геофенсинг. Он позволяет создать виртуальные границы вокруг важных мест и определять, когда устройство входит в них или выходит. Совмещая геофенсинг с живым GPS-трекингом, мы получаем более умную систему мониторинга: она не только следит за устройством, но и сообщает о событиях в нужный момент. Давай разберёмся, как это сделать. А перед этим загляни на вики-страницу GeoLinker, чтобы посмотреть полную спецификацию платы.

Как работает мультизонный геофенсинг#

Система начинает работу с включения GeoLinker. Плата ищет SIM-карту и подключается к сети — после этого стоит на некоторое время вынести плату на улицу, чтобы она поймала спутники GPS. Перед прошивкой нужно задать несколько геозон, указав для каждой широту, долготу и радиус. В этом проекте создаются четыре зоны: «Дом», «Универ», «Работа» и «Запретная зона». Когда инициализация завершена, GPS-модуль непрерывно определяет текущие координаты устройства. Полученные координаты сравниваются с границами всех настроенных геозон, чтобы понять, находится устройство внутри каждой из них или снаружи.

Система постоянно отслеживает перемещение устройства и проверяет, не изменился ли статус по какой-нибудь геозоне. Когда устройство входит в зону, система фиксирует событие и формирует оповещение. Точно так же оповещение формируется и при выходе из зоны. Для обычных зон — «Дом», «Универ», «Работа» — при входе и выходе на зарегистрированный номер уходит SMS. Такие оповещения позволяют в реальном времени следить за перемещениями отслеживаемого устройства.

Для запретной зоны создаётся отдельная геозона. Когда устройство входит в неё или выходит, система не только отправляет SMS, но и дополнительно инициирует автоматический звонок. Звонок гарантирует, что критичное оповещение, связанное с безопасностью, получит внимание немедленно и не останется незамеченным.

Каждые 30 секунд устройство выгружает свои координаты и уровень сигнала в облако CircuitDigest, что даёт трекинг в реальном времени и удалённый мониторинг через дашборд.

Замечание: помимо этого в примерах Arduino IDE есть ещё несколько готовых скетчей — продвинутый трекинг, инициация звонка, автоматический сон и другие. Если захочется расширить проект, потрать немного времени и посмотри их, они того стоят.

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

Ниже — компоненты, без которых систему оповещений о геозонах не собрать.

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

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

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

Плата GeoLinker GL868_ESP32 в жёлтом корпусе, вид с двух сторон

Всё железо системы: плата GeoLinker и аккумулятор. Корпус — по желанию.

Разбор кода#

Код написан с использованием библиотек GeoLinker и GPS. В нём есть места, где нужно поправить координаты, чтобы геофенсинг работал так, как тебе нужно. В зависимости от местоположения формируются разные оповещения. Кроме того, данные с заданным интервалом отправляются в облако CircuitDigest.

// ============================================================================
// Configuration
// ============================================================================
#define DEVICE_ID            "YourdeviceID"
#define API_KEY              "YourAPIKey"
#define ALERT_NUMBER         "YourPhoneNumber"

#define GPS_TIMEOUT          30000UL
#define GPS_MAX_RETRIES      3
#define GPS_POLL_INTERVAL    5000UL
#define CLOUD_SEND_INTERVAL  30          // seconds
#define GPS_LOSS_ALERT_POLLS 3

#define CALL_RING_DURATION   15000UL
#define CALL_PENDING_DELAY   3000UL

#define TIME_OFFSET_HOURS    5
#define TIME_OFFSET_MINS     30

#define HYSTERESIS_METERS    20.0
#define CIPSHUT_SETTLE_MS    2000UL

#ifndef DEG_TO_RAD
  static constexpr double DEG_TO_RAD = M_PI / 180.0;

Здесь собраны все важные настройки, используемые по всей программе: идентификатор устройства, ключ API для облака, номер для оповещений, таймаут GPS, интервал опроса и интервал выгрузки в облако. Когда все эти значения лежат в одном месте, систему проще настраивать и поддерживать, не залезая в основную логику.

// ============================================================================
// Geofence zones
// restricted = false → SMS only on enter/exit
// restricted = true  → SMS + voice call on enter/exit
// ============================================================================
struct GeofenceZone {
  const char *name;
  double      latitude;
  double      longitude;
  double      radiusMeters;
  bool        restricted;
  bool        insideNow;    // runtime — persisted to NVS on every change
};

GeofenceZone zones[] = {
  { "Home",            12.971600,  77.594600,  150.0,  false,  false },
  { "College",         12.934500,  77.626000,  200.0,  false,  false },
  { "Office",          12.958000,  77.650000,  200.0,  false,  false },
  { "Restricted Area", 11.011158,  77.013307,  200.0,  true,   false },
};
constexpr int ZONE_COUNT = sizeof(zones) / sizeof(zones[0]);

В этой части описаны все геозоны системы. У каждой есть имя, координаты центра, радиус и тип оповещения. Радиус задаёт размер виртуальной границы, а флаг restricted определяет, будет зона присылать только SMS или SMS вместе со звонком. Все геозоны хранятся в массиве, поэтому система может следить за несколькими зонами одновременно. Широту и долготу в массиве zones[] нужно поменять под свои задачи.

// ============================================================================
// GPS acquisition
// ============================================================================
static bool getValidLocation(GPSData *gps) {
  Serial.println("[GPS] Acquiring fix...");
  for (int retry = 0; retry < GPS_MAX_RETRIES; retry++) {
    Serial.printf("[GPS] Attempt %d/%d\n", retry + 1, GPS_MAX_RETRIES);
    uint32_t start = millis();
    while (millis() - start < GPS_TIMEOUT) {
      GeoLinker.update();
      if (GeoLinker.getLocationNow(gps) && gps->valid) {
        Serial.printf("[GPS] Fix: %.6f, %.6f  Sats:%d\n",
                      gps->latitude, gps->longitude, gps->satellites);
        return true;
      }
      uint32_t sub = millis();
      while (millis() - sub < 1000) { GeoLinker.update(); delay(25); }
    }
    Serial.println("[GPS] Timed out, retrying...");
  }
  Serial.println("[GPS] All retries failed.");
  return false;

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

  for (int i = 0; i < ZONE_COUNT; i++) {
    double dist        = haversineDistance(gps->latitude, gps->longitude,
                                           zones[i].latitude, zones[i].longitude);
    double enterThresh = zones[i].radiusMeters;
    double exitThresh  = zones[i].radiusMeters + HYSTERESIS_METERS;

    // Hysteresis: use wider exit threshold once inside
    bool nowInside = zones[i].insideNow ? (dist <= exitThresh)
                                        : (dist <= enterThresh);

    Serial.printf("[FENCE] %-18s | %7.1f m | enter:%.0f exit:%.0f | %-7s | %s\n",
                  zones[i].name, dist, enterThresh, exitThresh,
                  nowInside ? "INSIDE" : "outside",
                  zones[i].restricted ? "RESTRICTED" : "normal");

    if (nowInside != zones[i].insideNow) {
      zones[i].insideNow = nowInside;
      stateChanged = true;

      if (!firstFixDone) {
        // Silent sync — just record position, no alert
        Serial.printf("[FENCE] (silent sync) %s → %s\n",
                      zones[i].name, nowInside ? "inside" : "outside");
      } else {
        // Genuine transition after first fix — alert
        sendGeofenceAlert(zones[i].name, nowInside,
                          zones[i].restricted, gps, hasLoc);
      }
    }

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

Обрати внимание на HYSTERESIS_METERS: порог выхода из зоны берётся чуть больше порога входа. Без этого гистерезиса дрожание GPS на самой границе давало бы поток ложных срабатываний «вошёл — вышел — вошёл».

// ============================================================================
// Geofence alert dispatcher
// ============================================================================
static void sendGeofenceAlert(const char *zoneName, bool entered,
                              bool restricted, GPSData *gps, bool hasLoc) {
  const char *verb = entered ? "ENTERED" : "LEFT";
  Serial.printf("[ALERT] %s %s  restricted:%s\n",
                verb, zoneName, restricted ? "YES" : "NO");

  char msg[256];
  buildAlertMessage(msg, sizeof(msg), verb, zoneName, gps, hasLoc);
  sendReliableSMS(ALERT_NUMBER, msg);

  if (restricted && !callInProgress && !pendingCall) {
    Serial.println("[ALERT] Scheduling voice call in 3 s...");
    pendingCall      = true;
    pendingCallTimer = millis();
  }

Эта функция формирует уведомления при каждом событии геофенсинга. Сначала собирается подробное сообщение с типом события и информацией о местоположении, и оно уходит по SMS. Если зона помечена как запретная, система дополнительно планирует звонок на зарегистрированный номер.

Замечание: в репозитории на GitHub есть также код-заглушка (spoof code) — с ним можно проверить систему, не вставая с места.

Что делать, если не работает#

Ниже — проблемы, которые вылезли при сборке системы, и способы, которыми их удалось решить. Посмотри на них, если столкнёшься с чем-то похожим.

ПроблемаПричинаРешение
Не приходят SMS-оповещенияSIM-карта не зарегистрирована в сети GSM или не хватает баланса на SMSПроверь статус регистрации в сети, убедись, что на SIM есть баланс для SMS и что уровень сигнала достаточный
Не срабатывает звонок для запретной зоныНеправильно указан номер телефона или недоступна услуга звонковПроверь номер для оповещений в коде и убедись, что SIM поддерживает голосовые вызовы
GPS не определяет координатыСлабый сигнал GPS или работа в помещенииВынеси устройство на улицу, где открыто небо, и дождись, пока поймаются спутники
Постоянные ложные оповещения о входе и выходеДрожание GPS или слишком маленький радиус геозоныУвеличь радиус геозоны и убедись, что точность GPS достаточна для твоей задачи
Система неожиданно перезагружаетсяНе хватает питания в момент передачи по GSMИспользуй полностью заряженный Li-Ion аккумулятор 3,7 В, способный отдать пиковый ток GSM
После перезапуска устройство показывает неверный статус геозоныПредыдущее состояние геозон сохранилось некорректноПроверь работу памяти NVS и убедись, что состояния геозон записываются до потери питания
Звонок приходит, а SMS нет — или наоборотВозможно, твой номер не в белом спискеУбедись, что зарегистрированный номер добавлен в белый список и для звонков, и для SMS

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

Ниже показан живой трекинг платы GeoLinker. Плата непрерывно получает текущие координаты GPS и передаёт данные о местоположении в облако Circuit Digest с интервалом 30 секунд.

Карта живого трекинга в дашборде CircuitDigest Cloud

Трек устройства на карте: точки координат, приходящие каждые 30 секунд.

На следующем изображении показаны виртуальные границы (геозоны), созданные для системы. Координаты этих зон заданы в программе, прошитой в плату GeoLinker. Геозоны соответствуют конкретным местам — «Дом», «Универ», «Работа» и «Запретная зона». Как только система входит в любую из этих границ или выходит из неё, автоматически срабатывают соответствующие оповещения.

Геозоны «Дом», «Универ» и «Работа» на карте в GeoLinker GPS Visualizer

Виртуальные границы зон в GeoLinker GPS Visualizer.

На этом экране видна информация живого трекинга, приходящая с платы GeoLinker: дата и время, широта, долгота, скорость и процент заряда аккумулятора (если он подключён к системе). Кроме того, в разделе payload лежат остальные данные, которые передало устройство.

Таблица истории перемещений и телеметрии устройства GeoLinker

История: отметка времени, устройство, координаты, скорость, заряд и payload в JSON.

На изображении ниже — оповещения о входе и выходе для разных геозон. Когда устройство входит в заданную геозону или выходит из неё, система автоматически отправляет SMS зарегистрированному пользователю. В сообщении указаны тип события (вход или выход), название геозоны и текущие координаты GPS.

SMS-оповещения о входе в геозону и выходе из неё

SMS вида ENTERED College и LEFT College с координатами и ссылкой на карту.

Если же устройство входит в запретную зону или выходит из неё, система формирует SMS так же, как и для обычных зон. Но чтобы критичное уведомление точно не осталось незамеченным, она вдобавок автоматически звонит зарегистрированному пользователю. Такой двойной механизм даёт дополнительный уровень безопасности: он немедленно привлекает внимание к событиям в запретных зонах. В результате пользователь узнаёт о пересечении границы запретной зоны и по SMS, и по голосовому вызову. Кстати, на той же плате GeoLinker мы собрали ESP32-based Interactive Voice Response (IVR) System — посмотри, как там сведены вместе голосовое взаимодействие и удалённая связь.

Живая демонстрация#

Как система обнаруживает и отслеживает несколько заданных географических зон в реальном времени, можно посмотреть на видео: Multi-Geofencing System in action.

Применение и ограничения#

В таблице ниже собраны области применения системы и её ограничения. Применения помогают понять, где система пригодится, а ограничения — какие факторы влияют на её работу и надёжность.

ПрименениеОграничение
1Управление парком в логистике и службах доставкиДля точного определения местоположения нужен доступный сигнал GPS
2Контроль въезда и выезда машин с территории дома, офиса, кампусаВ тоннелях, под землёй и в плотной городской застройке точность GPS падает
3Безопасность личного автомобиля и защита от угонаПри плохой связи доставка SMS и звонков может задерживаться
4Системы уведомлений на основе местоположения в реальном времениПостоянная работа GPS и GSM увеличивает энергопотребление
5Контроль запретных зон и задачи безопасностиНужно периодически следить за состоянием SIM-карты и услуг передачи данных

Итог#

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

Полный код#

Полный скетч, схемы и файлы проекта лежат в репозитории на GitHub. Ниже — полный листинг оттуда, чтобы всё было под рукой.

/*
 * MultiZoneGeofence.ino
 * GL868_ESP32 Library — Multi-Zone Geofencing with Cloud Tracking (v12)
 *
 * FIXES vs v11:
 *  [F1] SMS fires only once on ENTER, once on EXIT — never while staying inside:
 *       - loadZoneStates() now RESTORES insideNow from NVS flash instead of
 *         clearing it. prefs.clear() was the root cause — it reset insideNow
 *         to false every boot so checkGeofences() always saw a fresh "entry"
 *         and sent a new SMS on every poll cycle.
 *       - A firstFixDone flag is used so the very first fix after a cold boot
 *         does a silent boundary sync (sets insideNow without alerting) before
 *         alerts are armed. This prevents false ENTER alerts on power-on.
 *       - checkGeofences() only calls sendGeofenceAlert() when
 *         nowInside != zones[i].insideNow (genuine transition).
 *
 *  [F2] Zone coordinates corrected back to original values (v11 had
 *       Office and Restricted Area lat/lon swapped in zones[]).
 *
 * ALL WORKING FEATURES RETAINED FROM v11:
 *  - Raw AT SMS with CRLF line endings (no "No SMS prompt" error)
 *  - GPRS detach before SMS/call, reattach after
 *  - Non-blocking voice call ticker
 *  - Manual GPRS attach + custom HTTP POST (no library sleep loop)
 *  - GPS-loss SMS alert
 *  - Zone state saved to NVS on every transition
 */

#include <GL868_ESP32.h>
#include <math.h>
#include <Preferences.h>

// ============================================================================
// Configuration
// ============================================================================
#define DEVICE_ID            "YourdeviceID"
#define API_KEY              "YourAPIKey"
#define ALERT_NUMBER         "YourPhoneNumber"

#define GPS_TIMEOUT          30000UL
#define GPS_MAX_RETRIES      3
#define GPS_POLL_INTERVAL    5000UL
#define CLOUD_SEND_INTERVAL  30          // seconds
#define GPS_LOSS_ALERT_POLLS 3

#define CALL_RING_DURATION   15000UL
#define CALL_PENDING_DELAY   3000UL

#define TIME_OFFSET_HOURS    5
#define TIME_OFFSET_MINS     30

#define HYSTERESIS_METERS    20.0
#define CIPSHUT_SETTLE_MS    2000UL

#ifndef DEG_TO_RAD
  static constexpr double DEG_TO_RAD = M_PI / 180.0;
#endif

// ============================================================================
// Geofence zones
// restricted = false → SMS only on enter/exit
// restricted = true  → SMS + voice call on enter/exit
// ============================================================================
struct GeofenceZone {
  const char *name;
  double      latitude;
  double      longitude;
  double      radiusMeters;
  bool        restricted;
  bool        insideNow;    // runtime — persisted to NVS on every change
};

GeofenceZone zones[] = {
  { "Home",            12.971600,  77.594600,  150.0,  false,  false },
  { "College",         12.934500,  77.626000,  200.0,  false,  false },
  { "Office",          12.958000,  77.650000,  200.0,  false,  false },
  { "Restricted Area", 11.011158,  77.013307,  200.0,  true,   false },
};
constexpr int ZONE_COUNT = sizeof(zones) / sizeof(zones[0]);
static_assert(ZONE_COUNT > 0, "zones[] must not be empty");

// ============================================================================
// Runtime state
// ============================================================================
static Preferences  prefs;
static uint32_t     lastPollTime     = 0;
static uint32_t     lastCloudPush    = 0;
static int          gpsFailStreak    = 0;
static bool         gpsLossAlertSent = false;
static bool         gprsAttached     = false;

static bool         callInProgress   = false;
static uint32_t     callStartTime    = 0;
static bool         pendingCall      = false;
static uint32_t     pendingCallTimer = 0;

// [F1] After cold boot, first fix silently syncs insideNow without alerting.
// This prevents false ENTER SMS when the device powers on inside a zone.
static bool         firstFixDone     = false;

// ============================================================================
// [F1] Zone state persistence — RESTORE from flash, never clear on boot
// ============================================================================
static void saveZoneStates() {
  prefs.begin("gfence", false);
  for (int i = 0; i < ZONE_COUNT; i++) {
    char key[8];
    snprintf(key, sizeof(key), "z%d", i);
    prefs.putBool(key, zones[i].insideNow);
  }
  prefs.end();
}

static void loadZoneStates() {
  prefs.begin("gfence", true);   // read-only open
  for (int i = 0; i < ZONE_COUNT; i++) {
    char key[8];
    snprintf(key, sizeof(key), "z%d", i);
    // getBool returns the stored value, or false if key doesn't exist yet
    zones[i].insideNow = prefs.getBool(key, false);
  }
  prefs.end();

  Serial.println("[INIT] Zone states loaded from NVS:");
  for (int i = 0; i < ZONE_COUNT; i++) {
    Serial.printf("       %s → %s\n",
                  zones[i].name,
                  zones[i].insideNow ? "INSIDE (remembered)" : "outside");
  }
}

// ============================================================================
// 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 (direct AT commands, CRLF line endings)
// ============================================================================
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] AT+CMGS → %s\n", number);

  // Wait up to 8 s for '>' prompt
  uint32_t t = millis();
  bool gotPrompt = false;
  while (millis() - t < 8000) {
    if (mdm.available()) {
      if (mdm.read() == '>') { gotPrompt = true; break; }
    }
    delay(10);
  }

  if (!gotPrompt) {
    Serial.println("[SMS] No '>' prompt — aborting.");
    mdm.write(0x1B);   // ESC
    return false;
  }

  // Convert bare \n → \r\n on the wire
  for (const char *p = message; *p; p++) {
    if (*p == '\n' && (p == message || *(p - 1) != '\r')) mdm.write('\r');
    mdm.write(*p);
  }
  mdm.write(0x1A);  // Ctrl+Z to send

  // Wait up to 30 s for +CMGS confirmation
  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 waiting for +CMGS.");
  return false;
}

static bool sendReliableSMS(const char *number, const char *message) {
  gprsDetach();
  bool ok = sendRawSMS(number, message);
  gprsReattach();
  return ok;
}

// ============================================================================
// Raw voice call
// ============================================================================
static bool makeRawCall(const char *number) {
  HardwareSerial &mdm = GeoLinker.getModemSerial();
  while (mdm.available()) mdm.read();   // flush

  mdm.printf("ATD%s;\r", number);
  Serial.printf("[CALL] ATD → %s\n", number);

  uint32_t t = millis();
  while (millis() - t < 5000) {
    if (mdm.available()) {
      String line = mdm.readStringUntil('\n');
      line.trim();
      if (line == "OK") { Serial.println("[CALL] Ringing."); return true; }
      if (line.indexOf("ERROR") >= 0 || line.indexOf("NO CARRIER") >= 0) {
        Serial.printf("[CALL] Rejected: %s\n", line.c_str());
        return false;
      }
    }
    delay(20);
  }
  Serial.println("[CALL] No explicit OK — assuming ringing.");
  return true;
}

// ============================================================================
// Non-blocking call ticker
// ============================================================================
static void tickCall() {
  if (!callInProgress) return;
  GeoLinker.update();
  if (millis() - callStartTime >= CALL_RING_DURATION) {
    GeoLinker.sendATCommand("H", 2000);
    Serial.println("[CALL] Ring timeout — hung up.");
    callInProgress = false;
    delay(500);
    gprsReattach();
  }
}

// ============================================================================
// Haversine distance (metres)
// ============================================================================
static double haversineDistance(double lat1, double lon1,
                                double lat2, double lon2) {
  const double R = 6371000.0;
  double dLat = (lat2 - lat1) * DEG_TO_RAD;
  double dLon = (lon2 - lon1) * DEG_TO_RAD;
  double a = sin(dLat / 2) * sin(dLat / 2) +
             cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) *
             sin(dLon / 2) * sin(dLon / 2);
  return R * 2.0 * atan2(sqrt(a), sqrt(1.0 - a));
}

static void sanitiseForSMS(char *dst, int dstLen, const char *src) {
  int j = 0;
  for (int i = 0; src[i] && j < dstLen - 1; i++) {
    char c = src[i];
    if (c == '\r') continue;
    if (c == '"')  { dst[j++] = '\''; continue; }
    dst[j++] = c;
  }
  dst[j] = '\0';
}

// ============================================================================
// SMS body builder
// ============================================================================
static void buildAlertMessage(char *buf, int bufLen,
                              const char *verb, const char *zoneName,
                              GPSData *gps, bool hasLoc) {
  char safe[64];
  sanitiseForSMS(safe, sizeof(safe), zoneName);
  int bat = GeoLinker.getBatteryPercent();

  if (hasLoc) {
    snprintf(buf, bufLen,
             "%s %s\n%.6f,%.6f\nSpd:%.1f Bat:%d%%\nmaps.google.com/?q=%.6f,%.6f",
             verb, safe,
             gps->latitude, gps->longitude,
             gps->speed, bat,
             gps->latitude, gps->longitude);
  } else {
    snprintf(buf, bufLen,
             "%s %s\nNo GPS fix\nBat:%d%%",
             verb, safe, bat);
  }
}

// ============================================================================
// Geofence alert dispatcher
// ============================================================================
static void sendGeofenceAlert(const char *zoneName, bool entered,
                              bool restricted, GPSData *gps, bool hasLoc) {
  const char *verb = entered ? "ENTERED" : "LEFT";
  Serial.printf("[ALERT] %s %s  restricted:%s\n",
                verb, zoneName, restricted ? "YES" : "NO");

  char msg[256];
  buildAlertMessage(msg, sizeof(msg), verb, zoneName, gps, hasLoc);
  sendReliableSMS(ALERT_NUMBER, msg);

  if (restricted && !callInProgress && !pendingCall) {
    Serial.println("[ALERT] Scheduling voice call in 3 s...");
    pendingCall      = true;
    pendingCallTimer = millis();
  }
}

// ============================================================================
// GPS-loss alert
// ============================================================================
static void sendGPSLossAlert() {
  char msg[160];
  snprintf(msg, sizeof(msg),
           "GPS Loss!\nDevice:%s\nFailed:%d polls\nBat:%d%%",
           DEVICE_ID, gpsFailStreak, GeoLinker.getBatteryPercent());
  Serial.println("[GPS] Sending GPS-loss SMS...");
  sendReliableSMS(ALERT_NUMBER, msg);
}

// ============================================================================
// GPS acquisition
// ============================================================================
static bool getValidLocation(GPSData *gps) {
  Serial.println("[GPS] Acquiring fix...");
  for (int retry = 0; retry < GPS_MAX_RETRIES; retry++) {
    Serial.printf("[GPS] Attempt %d/%d\n", retry + 1, GPS_MAX_RETRIES);
    uint32_t start = millis();
    while (millis() - start < GPS_TIMEOUT) {
      GeoLinker.update();
      if (GeoLinker.getLocationNow(gps) && gps->valid) {
        Serial.printf("[GPS] Fix: %.6f, %.6f  Sats:%d\n",
                      gps->latitude, gps->longitude, gps->satellites);
        return true;
      }
      uint32_t sub = millis();
      while (millis() - sub < 1000) { GeoLinker.update(); delay(25); }
    }
    Serial.println("[GPS] Timed out, retrying...");
  }
  Serial.println("[GPS] All retries failed.");
  return false;
}

// ============================================================================
// [F1] Geofence checker
//
// firstFixDone == false (cold boot):
//   Silently sync insideNow to current position. No alerts fired.
//   This prevents a false "ENTERED" SMS when the device powers on
//   inside a zone it was already in.
//
// firstFixDone == true (normal operation):
//   Alert only on genuine transition (nowInside != zones[i].insideNow).
//   While staying inside a zone, insideNow remains true and no alert fires.
//   Alert fires exactly once on ENTER and once on EXIT.
// ============================================================================
static void checkGeofences(GPSData *gps, bool hasLoc) {
  if (!hasLoc) { Serial.println("[FENCE] No fix — skipping."); return; }

  bool stateChanged = false;

  for (int i = 0; i < ZONE_COUNT; i++) {
    double dist        = haversineDistance(gps->latitude, gps->longitude,
                                           zones[i].latitude, zones[i].longitude);
    double enterThresh = zones[i].radiusMeters;
    double exitThresh  = zones[i].radiusMeters + HYSTERESIS_METERS;

    // Hysteresis: use wider exit threshold once inside
    bool nowInside = zones[i].insideNow ? (dist <= exitThresh)
                                        : (dist <= enterThresh);

    Serial.printf("[FENCE] %-18s | %7.1f m | enter:%.0f exit:%.0f | %-7s | %s\n",
                  zones[i].name, dist, enterThresh, exitThresh,
                  nowInside ? "INSIDE" : "outside",
                  zones[i].restricted ? "RESTRICTED" : "normal");

    if (nowInside != zones[i].insideNow) {
      zones[i].insideNow = nowInside;
      stateChanged = true;

      if (!firstFixDone) {
        // Silent sync — just record position, no alert
        Serial.printf("[FENCE] (silent sync) %s → %s\n",
                      zones[i].name, nowInside ? "inside" : "outside");
      } else {
        // Genuine transition after first fix — alert
        sendGeofenceAlert(zones[i].name, nowInside,
                          zones[i].restricted, gps, hasLoc);
      }
    }
  }

  if (stateChanged) saveZoneStates();

  // Arm alerts after the first real fix is processed
  if (!firstFixDone) {
    firstFixDone = true;
    Serial.println("[FENCE] First-fix sync complete — alerts now armed.");
  }
}

// ============================================================================
// Cloud push
// ============================================================================
static void cloudPush(GPSData *gps, bool hasLoc) {
  if (!gprsAttached) { gprsReattach(); if (!gprsAttached) return; }

  GeoLinker.json.clear();
  const GPSData &src = hasLoc ? *gps : GeoLinker.getLastGPS();
  GeoLinker.json.addDataPoint(src,
                              GeoLinker.getBatteryPercent(),
                              GeoLinker.getSignalStrength());
  char payload[1024];
  if (!GeoLinker.json.build(payload, sizeof(payload))) {
    Serial.println("[CLOUD] JSON build failed."); return;
  }

  Serial.printf("[CLOUD] Push: %.6f, %.6f  Bat:%d%%\n",
                src.latitude, src.longitude, 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);
  if (code == -1) {
    Serial.println("[CLOUD] Failed — will reattach next push.");
    gprsAttached = false;
  }
}

// ============================================================================
// setup()
// ============================================================================
void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("==============================================");
  Serial.println(" Multi-Zone Geofencing  GL868_ESP32 (v12)");
  Serial.println("==============================================");

  // [F1] Restore remembered zone positions from NVS (does NOT clear them)
  loadZoneStates();

  GeoLinker.setOperatingMode(MODE_SMS_CALL);
  GeoLinker.setTimeOffset(TIME_OFFSET_HOURS, TIME_OFFSET_MINS);
  GeoLinker.enableFullPowerOff(false);
  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);
  }

  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.");
  }

  GeoLinker.gpsOn();
  Serial.println("[GPS] GPS powered on.");

  Serial.println("\nConfigured Zones:");
  Serial.println("  #  Name               Lat         Lon        Radius   Alert     Inside?");
  Serial.println("  -- ------------------ ----------- ---------- -------- --------- -------");
  for (int i = 0; i < ZONE_COUNT; i++) {
    Serial.printf("  %d  %-18s %.6f  %.6f  %.0f m    %-9s %s\n",
                  i + 1, zones[i].name,
                  zones[i].latitude, zones[i].longitude,
                  zones[i].radiusMeters,
                  zones[i].restricted ? "SMS+CALL" : "SMS only",
                  zones[i].insideNow  ? "YES (remembered)" : "no");
  }
  Serial.printf("\nAlert number  : %s\n", ALERT_NUMBER);
  Serial.printf("Poll interval : %lu ms\n", GPS_POLL_INTERVAL);
  Serial.printf("Cloud interval: %d s\n\n", CLOUD_SEND_INTERVAL);
  Serial.println("Monitoring started.\n");

  lastPollTime  = millis();
  lastCloudPush = millis();
}

// ============================================================================
// loop()
// ============================================================================
void loop() {
  GeoLinker.update();

  // Pending call state machine
  if (pendingCall && (millis() - pendingCallTimer >= CALL_PENDING_DELAY)) {
    if (!callInProgress) {
      gprsDetach();
      Serial.println("[CALL] Initiating voice call...");
      if (makeRawCall(ALERT_NUMBER)) {
        callInProgress = true;
        callStartTime  = millis();
      } else {
        Serial.println("[CALL] Dial failed — reattaching GPRS.");
        gprsReattach();
      }
      pendingCall = false;
    }
  }

  tickCall();

  // GPS poll gate
  if (millis() - lastPollTime < GPS_POLL_INTERVAL) return;
  lastPollTime = millis();

  if (callInProgress) {
    Serial.println("[LOOP] Call in progress — skipping poll.");
    return;
  }

  GPSData gps = {};
  bool hasLoc = getValidLocation(&gps);

  if (hasLoc) {
    if (gpsFailStreak > 0)
      Serial.printf("[GPS] Restored after %d failure(s).\n", gpsFailStreak);
    gpsFailStreak    = 0;
    gpsLossAlertSent = false;
  } else {
    gpsFailStreak++;
    Serial.printf("[GPS] Failure %d/%d\n", gpsFailStreak, GPS_LOSS_ALERT_POLLS);
    if (gpsFailStreak >= GPS_LOSS_ALERT_POLLS && !gpsLossAlertSent) {
      sendGPSLossAlert();
      gpsLossAlertSent = true;
    }
  }

  // STEP 1: geofence alerts (SMS first, cloud after)
  checkGeofences(&gps, hasLoc);

  // STEP 2: cloud push (only if no alert/call activity)
  if (!pendingCall && !callInProgress &&
      millis() - lastCloudPush >= (uint32_t)CLOUD_SEND_INTERVAL * 1000UL) {
    lastCloudPush = millis();
    cloudPush(&gps, hasLoc);
  }

  Serial.printf("[LOOP] Done. Next in %lu ms\n\n", GPS_POLL_INTERVAL);
}

Частые вопросы#

Как система понимает, что устройство вошло в зону или вышло из неё?

Координаты GPS устройства непрерывно сравниваются с координатами и радиусом каждой геозоны. Когда устройство пересекает границу, фиксируется событие входа или выхода.

Чем полезен геофенсинг?

Геофенсинг позволяет получать автоматические уведомления по событиям, связанным с местоположением, вместо того чтобы постоянно проверять положение устройства на карте.

Что происходит при пересечении геозоны?

Система автоматически формирует оповещения. В этом проекте для обычных зон отправляется SMS, а для запретных — SMS и звонок.

Зачем для запретной зоны нужен звонок?

Звонок сразу привлекает внимание и снижает вероятность пропустить важное оповещение, связанное с безопасностью.

Можно ли создать несколько геозон?

Да. Система поддерживает несколько геозон — «Дом», «Универ», «Работа», «Запретная зона» — и может следить за всеми одновременно.

По какой технологии определяется местоположение?

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

Как часто данные о местоположении уходят в облако?

При нормальной работе устройство выгружает данные в облако каждые 30 секунд.