Сценарий: на даче в Калужской области стоит Raspberry Pi с кучей датчиков. Интернет работает только по «белым спискам», VPN в Чехию не пробиться, а данные в MySQL копятся и ждут своего часа. В городе (Москва) есть второй Pi, который раз в день звонит на дачный GSM-модем и выкачивает все показатели. Дачная SIM-карта только принимает звонки — тратить деньги на исходящие вызовы не нужно.
Решение — GSM-туннель поверх голосового вызова (CSD) с передачей данных в режиме «модемного» соединения. В этой статье мы разберём полную архитектуру: от схемы подключения до Python-скриптов, которые управляют звонками и передают байты.
Обычный VoIP или VPN блокируются на уровне протоколов. Но голосовой канал (Circuit Switched Data, CSD) — это эмуляция модемного соединения поверх обычного звонка. Оператор видит его как «разговор», а внутри — поток байтов. Обнаружить и заблокировать такую передачу почти невозможно, потому что это просто аудиоданные, закодированные модемом.
Роли:
Нам понадобятся два комплекта: один на даче, другой в городе. Оба идентичны по подключению.
| Pin на Raspberry Pi | Pin на SIM800L | Примечание |
|---|---|---|
| GPIO 14 (TX) | RX | Передача данных от Pi → модуль |
| GPIO 15 (RX) | TX | Приём данных от модуля → Pi |
| GND | GND | Общий «минус» |
| НЕ подключать 5V! | VCC | Питание 3.7–4.2 В от внешнего источника |
Последовательный порт по умолчанию занят консолью отладки. Освободим его:
sudo raspi-config
# Interface Options → Serial Port → NO (для логина) → YES (включить порт)
sudo reboot
После перезагрузки порт будет доступен как /dev/serial0. Скорость по умолчанию у SIM800L — 9600 бод.
Создадим универсальный Python-класс, который будет использоваться на обеих сторонах. Он умеет отправлять AT-команды, проверять регистрацию в сети и управлять вызовами.
# gsm_modem.py
import serial
import time
import re
class GSM_MCP(micropython): # для полноты, но на обычном Python тоже работает
def __init__(self, port="/dev/serial0", baudrate=9600, timeout=5):
self.ser = serial.Serial(port, baudrate, timeout=timeout)
self.ser.flushInput()
self.ser.flushOutput()
self._debug = False
def debug(self, on=True):
self._debug = on
def _send_at(self, cmd, wait=0.5):
"""Отправляет AT-команду и возвращает ответ"""
self.ser.write((cmd + "\r\n").encode())
time.sleep(wait)
response = b""
while self.ser.in_waiting:
response += self.ser.read(self.ser.in_waiting)
if self._debug:
print(f"> {cmd}\n< {response.decode()}")
return response.decode()
def is_network_ok(self):
"""Проверяет, зарегистрирован ли модуль в сети"""
resp = self._send_at("AT+CREG?", 0.5)
return "+CREG: 0,1" in resp or "+CREG: 0,5" in resp
def signal_quality(self):
"""Возвращает уровень сигнала (0-31), где >10 — хорошо"""
resp = self._send_at("AT+CSQ", 0.3)
match = re.search(r"\+CSQ:\s*(\d+)", resp)
return int(match.group(1)) if match else 0
def answer_call(self):
"""Поднимает трубку при входящем звонке"""
self._send_at("ATA", 0.3)
def hangup(self):
"""Кладёт трубку"""
self._send_at("ATH", 0.3)
def set_auto_answer(self, rings=1):
"""Включает автоответ после N гудков"""
self._send_at(f"ATS0={rings}", 0.3)
def dial(self, number):
"""Инициирует звонок на номер"""
return self._send_at(f'ATD{number};', 2)
def get_call_status(self):
"""Возвращает статус вызова (RING, CONNECT, NO CARRIER, etc.)"""
# Читаем всё, что пришло за последние 100 мс
time.sleep(0.1)
data = self.ser.read(self.ser.in_waiting).decode().strip()
return data
def send_raw(self, data):
"""Отправляет сырые байты (текст или бинарные данные)"""
self.ser.write(data.encode() if isinstance(data, str) else data)
def read_raw(self, size=1024):
"""Читает сырые байты из порта"""
return self.ser.read(size)
На даче скрипт работает в фоновом режиме как демон. Он:
ATS0=1).# dacha_server.py — запускается на дачной Raspberry Pi
import mysql.connector
from gsm_modem import GSM_MCP
import time
import json
from datetime import datetime, timedelta
DB_CONFIG = {
"host": "localhost",
"user": "iot_user",
"password": "secure_pass",
"database": "sensors"
}
def fetch_daily_data():
"""Забирает данные из MySQL за последние 24 часа и возвращает JSON-строку"""
conn = mysql.connector.connect(**DB_CONFIG)
cursor = conn.cursor(dictionary=True)
yesterday = datetime.now() - timedelta(days=1)
query = """
SELECT timestamp, temperature, humidity, pressure, voltage
FROM sensor_log
WHERE timestamp > %s
ORDER BY timestamp
"""
cursor.execute(query, (yesterday,))
rows = cursor.fetchall()
cursor.close()
conn.close()
# Сжимаем в компактный JSON без пробелов
return json.dumps(rows, default=str, separators=(',',':'))
def main():
gsm = GSM_MCP()
gsm.debug(False)
# Ждём, пока модуль зарегистрируется в сети
for _ in range(30):
if gsm.is_network_ok():
print("✅ Зарегистрирован в сети")
break
time.sleep(1)
else:
print("❌ Нет сети")
return
# Включаем автоответ после 1 гудка
gsm.set_auto_answer(1)
print("📞 Жду звонка...")
while True:
status = gsm.get_call_status()
if "CONNECT" in status:
print("🔗 Соединение установлено, отправляю данные...")
# 1. Забираем данные из MySQL
payload = fetch_daily_data()
print(f"📦 Размер данных: {len(payload)} байт")
# 2. Отправляем в поток
gsm.send_raw(payload + "\n") # \n — маркер конца передачи
# 3. Даём время на передачу
time.sleep(3)
# 4. Вешаем трубку
gsm.hangup()
print("📴 Звонок завершён, жду следующего...")
time.sleep(0.5)
if __name__ == "__main__":
main()
\n в конце, чтобы принимающая сторона знала, что данные закончились. Для больших объёмов можно добавить заголовок с длиной сообщения.
В Москве скрипт запускается по крону раз в сутки. Он:
\n.# moscow_client.py — запускается по крону в Москве
from gsm_modem import GSM_MCP
import time
import json
from datetime import datetime
DACHA_PHONE = "+7XXX..." # Номер дачной SIM-карты
def main():
gsm = GSM_MCP()
gsm.debug(False)
# Проверяем сеть
if not gsm.is_network_ok():
print("❌ Нет сети, выходим")
return
# Звоним на дачу
print("📞 Звоню на дачу...")
gsm.dial(DACHA_PHONE)
# Ждём ответа (CONNECT) или отбоя (NO CARRIER)
start = time.time()
data_buffer = ""
while time.time() - start < 60: # таймаут 60 сек
response = gsm.get_call_status()
if "CONNECT" in response:
print("🔗 Соединение установлено, принимаю данные...")
# Читаем данные до символа \n
while True:
chunk = gsm.read_raw(256).decode(errors='ignore')
data_buffer += chunk
if "\n" in data_buffer:
break
time.sleep(0.1)
break
elif "NO CARRIER" in response:
print("❌ Абонент не ответил или сбросил")
break
time.sleep(0.3)
# Завершаем звонок (на всякий случай)
gsm.hangup()
if data_buffer:
# Удаляем лишние символы и парсим JSON
clean_data = data_buffer.strip().split("\n")[0]
try:
records = json.loads(clean_data)
print(f"✅ Получено {len(records)} записей")
# Сохраняем в файл с меткой времени
filename = f"dacha_data_{datetime.now().strftime('%Y%m%d_%H%M')}.json"
with open(filename, "w") as f:
json.dump(records, f, indent=2)
print(f"💾 Сохранено в {filename}")
except json.JSONDecodeError as e:
print(f"❌ Ошибка парсинга JSON: {e}")
print(f"Сырые данные: {data_buffer[:200]}...")
else:
print("⚠️ Данные не получены")
if __name__ == "__main__":
main()
Создаём systemd-сервис, чтобы скрипт стартовал при загрузке и перезапускался при падении:
sudo nano /etc/systemd/system/gsm-server.service
[Unit]
Description=GSM Data Server for Dacha
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/dacha_server.py
Restart=always
RestartSec=5
User=pi
[Install]
WantedBy=multi-user.target
sudo systemctl enable gsm-server
sudo systemctl start gsm-server
sudo systemctl status gsm-server
Добавляем задание в crontab для ежедневного запуска в 8:00 утра:
crontab -e
# Добавляем строку:
0 8 * * * /usr/bin/python3 /home/pi/moscow_client.py >> /home/pi/gsm_client.log 2>&1
| Симптом | Вероятная причина | Решение |
|---|---|---|
| Модуль не отвечает на AT | Нет питания / неправильная скорость порта | Проверьте питание (3.7–4.2 В) и baudrate=9600 |
| В ответ на звонок — только шум, нет данных | CSD-звонок заблокирован оператором | Попробуйте отправить данные через SMS как fallback |
| Модуль перезагружается при звонке | Недостаточная мощность питания | Поставьте конденсатор 1000 мкФ и блок питания на 3А |
Скрипт зависает на read_raw() |
Нет маркера окончания передачи | Убедитесь, что дача отправляет \n в конце |
| «NO CARRIER» сразу после набора | Номер набран неверно / сеть не поддерживает голосовые вызовы | Проверьте формат номера: +7XXXXXXXXXX |
✍️ Нашли ошибку или есть вопросы? Пишите в комментарии!
Теги: #IoT #GSM #RaspberryPi #CSD #обход_блокировок
Комментарии
Пока нет комментариев. Будьте первым!