Building an SMS and Call Forwarder with an Air724UG 4G Module and a Single-Board Computer
Turn an idle SIM card into a 24/7 SMS and call notification hub with one dev board, one 4G module, and a few dozen lines of Python.
Why build this?
Many of us have more than one SIM card: a bank backup number, a secondary card for verification codes, an IoT SIM, and so on. But carrying two phones all the time is not practical. If incoming SMS messages and calls on those cards could be forwarded to your main phone in real time, life gets much easier.
There are existing options such as cloud phones or SMS forwarding apps, but they usually depend on Android, Google services, or a subscription fee. This article takes a more hands-on route: connect a 4G module to a Raspberry Pi-like single-board computer over UART, listen for AT command URCs, decode PDU SMS payloads in real time, and push everything to Bark, a minimal push notification service for iOS and Android.
Hardware Checklist
| Component | Notes |
|---|---|
| PurplePi | A Raspberry Pi-like single-board computer running Ubuntu 20.04 with a 40-pin GPIO header |
| OpenLuat Air724UG core board | Cat.1 LTE module with AT command support and a UART interface (manual) |
| SIM card | Any Micro SIM with SMS and voice service enabled |
| Dupont wires | 5 female-to-female wires: 5V, 3.3V, GND, TX, RX |
Wiring
The PurplePi powers the Air724UG core board directly from the 40-pin header and talks to it over UART. No extra level shifter is needed. The module's VTTL pin sets the UART reference level, so connecting it to the PurplePi's 3.3V rail is enough to match the logic levels.
PurplePi 40-pin header Air724UG core board (left header)
┌─────────────────────┐ ┌─────────────────────────┐
│ Pin 2 (5V) ──────┼──────────────►│ VIN │
│ Pin 1 (3V3) ──────┼──────────────►│ VTTL │
│ Pin 6 (GND) ──────┼──────────────►│ GND │
│ Pin 8 (TXD) ──────┼──────────────►│ RXD1 │
│ Pin 10 (RXD) ◄─────┼───────────────│ TXD1 │
└─────────────────────┘ └─────────────────────────┘
Full Air724UG left-header pin order (top to bottom):
VIN, EN, VBAT, VBAT, GND, GND, PWRKEY, RST, 1V8, VTTL, RXD1, TXD1
Wiring summary (5 Dupont wires):
┌──────┬──────────────┬────────┬──────────────────────────┐
│ Color│ PurplePi │ Air724 │ Function │
├──────┼──────────────┼────────┼──────────────────────────┤
│ Red │ Pin 2 (5V) │ VIN │ 5V power │
│ Yellow│ Pin 1 (3V3) │ VTTL │ UART reference level │
│ Black│ Pin 6 (GND) │ GND │ Common ground │
│ Green│ Pin 8 (TXD) │ RXD1 │ PurplePi TX -> module RX │
│ Blue │ Pin 10 (RXD) │ TXD1 │ Module TX -> PurplePi RX │
└──────┴──────────────┴────────┴──────────────────────────┘
VTTLnote: the Air724UG core board exposes a 1.8V native UART internally, but the board already includes level-shifting circuitry. OnceVTTLis tied to 3.3V, the UART level is matched to the PurplePi GPIO, so no external level shifter is required. Do not connectVTTLto 5V. Most SBC GPIOs are not 5V tolerant and you may damage the pins.Power note: the Air724UG can draw burst currents up to about 2A while transmitting. Make sure the PurplePi's power adapter is strong enough. A 5V/3A supply is a good baseline.
Software Architecture
The data flow is straightforward:
SIM card receives SMS or call
│
▼
Air724UG 4G module
(emits URCs via AT commands)
│ UART
▼
PurplePi (/dev/ttyS0)
Python script watches serial
│
▼
Decode PDU / parse caller ID
│
▼
Bark Push API
(HTTPS GET)
│
▼
Phone receives a notification
Key AT Commands
Before writing code, it helps to know the core commands:
| Command | Purpose |
|---|---|
ATE0 |
Disable echo so the serial stream does not include the commands we send |
AT+CMGF=0 |
Use PDU mode for SMS instead of text mode for better compatibility |
AT+CNMI=2,2,0,0,0 |
Push new SMS directly to the serial port as +CMT URCs, no polling needed |
AT+CLIP=1 |
Enable caller ID so incoming calls emit +CLIP with the number |
After configuration, the module behaves like this:
- Incoming SMS -> serial output
+CMT: ,<length>followed by the PDU hex string on the next line - Incoming call -> serial output
RING, then+CLIP: "<number>",<type> - Missed / ended call -> serial output
NO CARRIER
Project Setup
1. Initialize a Python Project with uv
uv is an extremely fast Python package manager that can replace pip plus venv:
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.local/bin/env
# Create the project
mkdir -p ~/Projects && cd ~/Projects
uv init sms-forwarder
cd sms-forwarder
# Add dependencies
uv add pyserial requests
Generated pyproject.toml:
[project]
name = "sms-forwarder"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"pyserial>=3.5",
"requests>=2.32.4",
]
2. Verify Serial Communication
Before writing the full script, first make sure the module responds on the serial port:
import serial, time
ser = serial.Serial('/dev/ttyS0', 115200, timeout=2)
ser.reset_input_buffer()
ser.write(b'AT\r\n')
time.sleep(1)
resp = ser.read(ser.in_waiting or 64)
print(repr(resp))
# Expected output: b'AT\r\n\r\nOK\r\n'
ser.close()
If you get nothing back or only garbage, check:
- Whether the baud rate matches. The Air724UG defaults to 115200.
- Whether TX and RX are crossed correctly.
- Whether the module is already powered on. If not, you may need to pull the PWR pin low to boot it.
Core Code Walkthrough
PDU Decoding
PDU, short for Protocol Data Unit, is the binary encoding format used by GSM SMS. Compared with text mode, PDU mode handles Chinese text and multiple encodings more reliably. A simplified PDU structure looks like this:
[SMSC][PDU type][originating address][PID][DCS][timestamp][user data length][user data]
Important fields:
- DCS (Data Coding Scheme) determines the message encoding
0x00: GSM 7-bit for plain Latin text0x08: UCS-2 for Chinese, Japanese, and other Unicode text0x04: 8-bit data
- Originating address type:
0x91for international numbers,0xD0for alphanumeric senders such as operator names - UDHI flag: bit 6 of the first octet. When set, the SMS contains a User Data Header, usually for multipart messages
Core decoding helpers:
GSM7_BASIC = (
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ "
"!\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`"
"¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
def decode_gsm7_packed(data: bytes, num_chars: int) -> str:
"""Decode GSM 7-bit packed data."""
chars = []
total_bits = len(data) * 8
bit_pos = 0
for _ in range(num_chars):
if bit_pos + 7 > total_bits:
break
byte_idx = bit_pos // 8
bit_offset = bit_pos % 8
val = (data[byte_idx] >> bit_offset) & 0x7F
if byte_idx + 1 < len(data) and bit_offset > 1:
val |= (data[byte_idx + 1] << (8 - bit_offset)) & 0x7F
if val < len(GSM7_BASIC):
chars.append(GSM7_BASIC[val])
else:
chars.append("?")
bit_pos += 7
return "".join(chars)
def decode_ucs2(data: bytes) -> str:
"""Decode UCS-2, commonly used for Chinese SMS."""
return data.decode("utf-16-be", errors="replace")
The special part of GSM 7-bit encoding is that it is packed bitwise: each character uses 7 bits instead of 8, so you have to pull the bits out manually. UCS-2 is much simpler and is effectively UTF-16 big-endian in this context.
Full PDU Parsing
def parse_pdu(pdu_hex: str):
"""Parse an SMS-DELIVER PDU and return (sender, message)."""
pdu = pdu_hex.strip()
idx = 0
# SMSC information can be skipped
smsc_len = int(pdu[idx:idx + 2], 16)
idx += 2 + smsc_len * 2
# First octet
first_octet = int(pdu[idx:idx + 2], 16)
idx += 2
udhi = (first_octet >> 6) & 1 # whether UDH is present
# Sender number
oa_len = int(pdu[idx:idx + 2], 16)
idx += 2
oa_type = int(pdu[idx:idx + 2], 16)
idx += 2
oa_nibbles = oa_len + (oa_len % 2)
oa_hex = pdu[idx:idx + oa_nibbles]
idx += oa_nibbles
# Number decoding requires nibble swapping
if oa_type == 0x91: # international number
sender = "+" + swap_nibbles(oa_hex)
elif oa_type == 0xD0: # alphanumeric sender such as "China Mobile"
byte_len = (oa_len * 7 + 7) // 8
sender = decode_gsm7_packed(bytes.fromhex(oa_hex[:byte_len * 2]), oa_len)
else:
sender = swap_nibbles(oa_hex)
# PID + DCS + timestamp
idx += 2 # PID
dcs = int(pdu[idx:idx + 2], 16)
idx += 2
idx += 14 # SCTS timestamp (7 bytes)
# User data
udl = int(pdu[idx:idx + 2], 16)
idx += 2
ud_bytes = bytes.fromhex(pdu[idx:])
# Decode according to DCS
if (dcs & 0x0C) == 0x08:
message = decode_ucs2(ud_bytes)
elif (dcs & 0x0C) == 0x04:
message = ud_bytes.decode("latin-1")
else:
message = decode_gsm7_packed(ud_bytes, udl)
return sender.strip(), message.strip()
Nibble swapnote: GSM stores phone numbers in BCD with the high and low nibbles reversed in each byte. For example, the number13800138000is stored as31080031800F.
Incoming Call Detection
Incoming call detection depends on AT+CLIP=1, which enables caller ID URCs:
# Ring event
if line == "RING":
ring_caller = None
continue
# Caller number, usually right after RING
m_clip = re.match(r'\+CLIP:\s*"([^"]*)"', line)
if m_clip:
ring_caller = m_clip.group(1)
push("Incoming Call", f"Caller: {ring_caller}")
continue
# Call ended or missed
if line == "NO CARRIER":
if ring_caller:
push("Missed Call", f"Missed call from: {ring_caller}")
ring_caller = None
continue
Typical serial output during a call:
RING
+CLIP: "13800138000",161,,,,0
RING
+CLIP: "13800138000",161,,,,0
NO CARRIER
Bark Push Notifications
The Bark API is almost comically simple: one GET request is enough.
def push(title: str, body: str):
title_enc = quote(title, safe="")
body_enc = quote(body, safe="")
url = f"https://api.day.app/{BARK_KEY}/{title_enc}/{body_enc}"
requests.get(url, timeout=10)
Example notifications:
| Scenario | Title | Body |
|---|---|---|
| Incoming SMS | +8613800138000 |
[Bank] Your verification code is 123456... |
| Incoming call | Incoming Call | Caller: 13912345678 |
| Missed call | Missed Call | Missed call from: 13912345678 |
| Module error | Module Error | +CME ERROR: 10 |
| SIM status | SIM Status | +CPIN: READY |
Automatic Serial Reconnect
In production the serial port can disappear temporarily for all kinds of reasons: a loose USB cable, a module reboot, and so on. The script includes automatic reconnection:
except serial.SerialException as e:
log.error(f"Serial error: {e}, retrying in 5 seconds...")
push("Serial Error", str(e))
time.sleep(5)
ser.close()
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
init_modem(ser)
Run It as a System Service
Once the script is ready, use systemd to make it start automatically at boot.
Create the service file
# /etc/systemd/system/sms-forwarder.service
[Unit]
Description=SMS Forwarder - Air724UG to Bark Push
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ido
WorkingDirectory=/home/ido/Projects/sms-forwarder
ExecStart=/home/ido/Projects/sms-forwarder/.venv/bin/python main.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Important settings:
After=network-online.target: wait for networking before startup because push delivery needs the internetRestart=alwaysplusRestartSec=5: restart automatically after a crash, with a 5-second delayExecStartpoints directly at the Python interpreter inside the virtual environment, so no manual activation is needed
Enable and start it
sudo cp sms-forwarder.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable sms-forwarder # start on boot
sudo systemctl start sms-forwarder # start now
View logs
# Follow in real time
sudo journalctl -u sms-forwarder -f
# Last 50 lines
sudo journalctl -u sms-forwarder -n 50 --no-pager
Typical startup log output:
2026-03-31 15:46:34 [INFO] Opening /dev/ttyS0 at 115200 baud
2026-03-31 15:46:36 [INFO] >> AT -> OK
2026-03-31 15:46:38 [INFO] >> ATE0 -> OK
2026-03-31 15:46:40 [INFO] >> AT+CMGF=0 -> OK
2026-03-31 15:46:42 [INFO] >> AT+CNMI=2,2,0,0,0 -> OK
2026-03-31 15:46:44 [INFO] >> AT+CLIP=1 -> OK
2026-03-31 15:46:44 [INFO] Push sent successfully: [SMS Forwarder] daemon started on PurplePi
Pitfalls I Ran Into
1. Baud rate mismatch
The Air724UG defaults to 115200 baud. If communication looks flaky, you can brute-force scan the common rates:
for baud in [9600, 19200, 38400, 57600, 115200, 460800]:
ser = serial.Serial('/dev/ttyS0', baud, timeout=2)
ser.write(b'AT\r\n')
time.sleep(1)
resp = ser.read(ser.in_waiting or 64)
print(f"Baud {baud}: {repr(resp)}")
ser.close()
At the correct baud rate, you should get b'AT\r\n\r\nOK\r\n'.
2. Serial port already in use
If screen or another program is already using /dev/ttyS0, the script will raise serial.SerialException. Check for other processes first:
sudo fuser /dev/ttyS0
3. PDU mode vs text mode
Text mode, enabled with AT+CMGF=1, is easier to read, but Chinese support is weak and behavior varies across modules. PDU mode is part of the GSM standard and handles all encodings more reliably, so I recommend using it all the time.
4. Multipart SMS
Once an SMS exceeds the single-message limit, 160 characters for GSM 7-bit or 70 characters for UCS-2, it is split into multiple parts. The UDH in each part contains the concatenation metadata. My script currently forwards each segment separately. If you want to merge them, cache the fragments by reference number and sequence number, then stitch them back together after a timeout.
Possible Extensions
- Merge multipart SMS by caching fragments and reassembling them before pushing
- Forward to Telegram or WeCom by replacing Bark with the corresponding bot API
- Two-way SMS by sending messages with
AT+CMGSfrom a web UI or bot - Auto-reject and callback by sending
ATHon incoming calls and calling back through VoIP - Multi-SIM support by attaching multiple 4G modules to different UART ports and running one process per port
Wrap-Up
What I like about this setup is how lightweight it is. There is no Android stack, no extra phone, and no SIM tray gymnastics. A cheap Linux board plus an inexpensive 4G module is enough to keep a SIM online 24/7 with very low power consumption.
Core stack:
- Hardware: UART serial communication
- Protocol: GSM AT commands plus PDU parsing
- Software: Python,
pyserial, andrequests - Deployment:
systemd - Push: Bark API
The whole project comes in at well under 250 lines of code, but it still covers the full chain: SMS reception, call detection, PDU decoding, automatic reconnect, and Bark notification delivery. If you also have an idle SIM card that still needs to stay reachable, this is a fun weekend build.