Building an SMS and Call Forwarder with an Air724UG 4G Module and a Single-Board Computer

Building an SMS and Call Forwarder with an Air724UG 4G Module and a Single-Board Computer

April 1, 2026

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 │
   └──────┴──────────────┴────────┴──────────────────────────┘

VTTL note: the Air724UG core board exposes a 1.8V native UART internally, but the board already includes level-shifting circuitry. Once VTTL is tied to 3.3V, the UART level is matched to the PurplePi GPIO, so no external level shifter is required. Do not connect VTTL to 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 text
    • 0x08: UCS-2 for Chinese, Japanese, and other Unicode text
    • 0x04: 8-bit data
  • Originating address type: 0x91 for international numbers, 0xD0 for 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 swap note: GSM stores phone numbers in BCD with the high and low nibbles reversed in each byte. For example, the number 13800138000 is stored as 31080031800F.

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 internet
  • Restart=always plus RestartSec=5: restart automatically after a crash, with a 5-second delay
  • ExecStart points 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+CMGS from a web UI or bot
  • Auto-reject and callback by sending ATH on 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, and requests
  • 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.

Building an SMS and Call Forwarder with an Air724UG 4G Module and a Single-Board Computer | Cheng's Blog