Мультизонный геофенсинг на GeoLinker: SMS и звонок при входе в зону

Перевод статьи Multi-Geofencing System Using IoT with ESP32 & GPS Alerts — Vedhathiri, Circuit Digest.
Живой GPS-трекинг знаком всем: он показывает, где машина или устройство находится прямо сейчас. Но что, если система умеет больше, чем просто рисовать точку на карте? Тут-то и появляется геофенсинг.
Представь, что тебе прилетает уведомление в тот самый момент, когда машина отъехала от дома, добралась до универа, заехала на работу или сунулась в запретную зону. Вместо того чтобы раз за разом открывать карту, система сама сообщает, когда произошло что-то важное. Подумай сам: если ты следишь за машиной, разве хочется каждые пять минут открывать приложение и проверять её положение? В большинстве случаев тебя интересуют только конкретные места и конкретные события.
Например, хочется знать, когда машина доехала до универа, уехала с работы, вернулась домой или заехала туда, куда заезжать не следует. Вместо того чтобы весь день пялиться в карту, гораздо удобнее, чтобы система сама сообщала о таких событиях. В этом проекте мы соберём полноценную систему геофенсинга на плате GeoLinker GL868_ESP32 — это ESP32-S3 в паре с GSM-модемом SIM868.
Именно для этого и нужен геофенсинг. Он позволяет создать виртуальные границы вокруг важных мест и определять, когда устройство входит в них или выходит. Совмещая геофенсинг с живым GPS-трекингом, мы получаем более умную систему мониторинга: она не только следит за устройством, но и сообщает о событиях в нужный момент. Давай разберёмся, как это сделать. А перед этим загляни на вики-страницу GeoLinker, чтобы посмотреть полную спецификацию платы.
Как работает мультизонный геофенсинг#
Система начинает работу с включения GeoLinker. Плата ищет SIM-карту и подключается к сети — после этого стоит на некоторое время вынести плату на улицу, чтобы она поймала спутники GPS. Перед прошивкой нужно задать несколько геозон, указав для каждой широту, долготу и радиус. В этом проекте создаются четыре зоны: «Дом», «Универ», «Работа» и «Запретная зона». Когда инициализация завершена, GPS-модуль непрерывно определяет текущие координаты устройства. Полученные координаты сравниваются с границами всех настроенных геозон, чтобы понять, находится устройство внутри каждой из них или снаружи.
Система постоянно отслеживает перемещение устройства и проверяет, не изменился ли статус по какой-нибудь геозоне. Когда устройство входит в зону, система фиксирует событие и формирует оповещение. Точно так же оповещение формируется и при выходе из зоны. Для обычных зон — «Дом», «Универ», «Работа» — при входе и выходе на зарегистрированный номер уходит SMS. Такие оповещения позволяют в реальном времени следить за перемещениями отслеживаемого устройства.
Для запретной зоны создаётся отдельная геозона. Когда устройство входит в неё или выходит, система не только отправляет SMS, но и дополнительно инициирует автоматический звонок. Звонок гарантирует, что критичное оповещение, связанное с безопасностью, получит внимание немедленно и не останется незамеченным.
Каждые 30 секунд устройство выгружает свои координаты и уровень сигнала в облако CircuitDigest, что даёт трекинг в реальном времени и удалённый мониторинг через дашборд.
Замечание: помимо этого в примерах Arduino IDE есть ещё несколько готовых скетчей — продвинутый трекинг, инициация звонка, автоматический сон и другие. Если захочется расширить проект, потрать немного времени и посмотри их, они того стоят.
Что понадобится#
Ниже — компоненты, без которых систему оповещений о геозонах не собрать.
| № | Компонент | Зачем нужен | Замена |
|---|---|---|---|
| 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 В | Питание платы | LiPo-пакет |
| 4 | Кабель USB-C | Прошивка, отладка и зарядка | — |
Схема подключения#
Требования к железу минимальны: для работы нужны только плата GeoLinker и аккумулятор. Корпус опционален — он нужен лишь для защиты и приличного внешнего вида. Как только аккумулятор подключён к плате GeoLinker, система автоматически включается и начинает инициализацию.

Всё железо системы: плата 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 секунд.

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

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

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

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 секунд.