Sending SMS with USB Modem
Imagine a Linux computer installed in a bunker and controlling a doomsday machine. This control computer is fully authonomous and has no internet connection. The only way it (sometimes) communicate with the outside world is by GSM modem. If controlling computer discovers that the machine mailfunctions or needs a service, it sends a SMS to a service engineer. The engineer then comes and fix the machine. The bunker was sealed 20 years ago and was long forgotten … until recently a team of archeologists discovered it.
That’s how I know about it, because shortly after I was commissioned to update the Linux distribution on the control computer. You see, doomsday machine or not, according to the insurance policy no past-end-of-life OS is allowed in any bunker π
Well, maybe the story I just told you is slightly exaggerated π , but the overall sense is right.
On top of the new OS we also got to update the modem itself, because the world does not stay still and hardware becomes obsolete as much as software. Of course with both OS and modem update we need to be sure that everythign works as before.
The good news is that telecom standards live long and sending SMS through AT commands works in 2025 exactly the same as it worked in 2005 … or does it?
Traditional Way
The idea is simple. There are so-called AT commands, a plain-text telnet-style protocol, and it’s possible to send SMS using a sequence of AT commands. Literally all modems from the 80-s to the present day understand AT commands, and our only problem should be to find a way to send them to the modem and get the response back.
My modem is Teltonika TRM240 with a badass-looking ruggedized case and external antenna, but it connects to PC (and gets power) through a regular USB and in many other aspects similar to a consumer-grade USB dongles.
So, how do we send SMS? Teltonika wiki conveniently has a page with a step-by-step manual https://wiki.teltonika-networks.com/view/SMS_sending_command Let’s take a look.
When you connect the modem through the USB, the OS detects it and creates several /dev/ttyUSBx
devices to interact with the modem.
A device enumerated in the USB bus is able to expose in the system multiple ports, so there is no limitation on the number of control ports or data ports that can be found. USB modems with multiple control ports are very common, e.g. allowing different operations to be run in parallel in the different ports
β ModemManager docs
After connecting the modem it’s a good idea to check that the modem shows up in the lsusb
:
Bus 003 Device 010: ID 2c7c:0121 Quectel Wireless Solutions Co., Ltd. EC21 LTE modem
β Sometimes USB port can’t give modem enough power, so having a USB hub with external power is a good idea
Quectel EC21 is the name of the chip that my modem is based on:
It’s useful to know because you can refer to the chip manufacturer documentation about the supported AT commands.
As I said, the modem is represented by several /dev/ttyUSBx
devices, in my case by four different devices:
$ sudo dmesg | grep tty
[ 620.750378] usb 3-3: GSM modem (1-port) converter now attached to ttyUSB0
[ 620.750539] usb 3-3: GSM modem (1-port) converter now attached to ttyUSB1
[ 620.750623] usb 3-3: GSM modem (1-port) converter now attached to ttyUSB2
[ 620.750679] usb 3-3: GSM modem (1-port) converter now attached to ttyUSB3
Now we need to find out (by trial and error, I guess, the manufacturer wiki is vague about it) which tty device is responsible for AT commands. In our case it’s /dev/ttyUSB2
.
Then the manual suggests connecting to this port using either screen
or minicom
, e.g.
sudo minicom -D /dev/ttyUSB2 -b 115200
and getting an interactive shell with the modem:
If you are curious, minicom
is a reimplementation of some ancient utility from the times when connecting to the terminal was done through physical RS232. That said, neither screen
or minicom
are necessary, we can do everything just using printf
and cat
(or by read
and write
to the file descriptor if you work from some other language) π€·
Result 1:
What you’ve just read is the “classical” way to send SMS through a modem. It’s described in wikis, blog posts, and that’s what ChatGPT will suggest you if you ask.
It works, but when I started to experiment with it more, I discovered that it works glitchy.
Sometimes modem refused to switch to the echo mode (ATE1
). Sometimes echo worked, but some characters disappeared. Sometimes AT commands hiccup-ed or stopped working altogether.
My first thought was that I messed up with terminal device settings (which is the pretty arcane topic), but then I quickly realized that the reason is more simple: something was accessing the same tty device in parallel with me:
$ sudo fuser /dev/ttyUSB2
/dev/ttyUSB2: 2041
and this “something” is ModemManager.
$ ps aux | grep 2041
root 2041 0.0 0.0 392540 14824 ? Ssl 13:40 0:01 /usr/sbin/ModemManager
ModemManager
ModemManager is a system daemon which controls WWAN (2G/3G/4G/5G) devices and connections.
Alongside NetworkManager, ModemManager is the default mobile broadband management system in most standard GNU/Linux distributions (Debian, Fedora, Ubuntu, Arch Linuxβ¦), and is also available in custom systems built with e.g. buildroot, yocto/openembedded or ptxdist.
ModemManager may also be used in routers running openwrt (integrated with netifd as a new protocol handler), or to manage both voice and data in mobile phones running postmarketos (PINE64 PinePhone, BQ Aquaris X5, OnePlus 6β¦).
It’s not a surprise that ModemManager … well, manages /dev/ttyUSBx
devices for USB modems. Honestly, I’m not even sure why it needs the access to the AT commands because studying debug logs shows that pretty much evertything is done using QMI. But apparently it polls some data through AT commands and direct reading/writing /dev/ttyUSBx
clash with ModemManager and break things.
There are several workarounds that let us send SMS with AT commands the “traditional way”. I will list them all, but if you are interested in how to do things the proper way (or, better to say ModemManager way), you can jump straight to the Sending SMS with ModemManager.
Workaround A (hostile): Disabling ModemManager
… by masking ModemManager.service
The dumbest and most straightforward way is to disable and mask ModemManager.service
. In my case it was actually an option, because as I said control computer has only one modem and we use it in a primitive way, so ModemManager
is unnecessary.
Of course, disabling a system component that was put there by your distribution maintainers doesn’t feel good. Also, what if your use case goes beyond sending and receiving SMS? I’m not sure that I would like to figure out how to manually set up IP connectivity with my modem without the help of ModemManager.
… by preventing ModemManager from managing our modem with udev rules
It’s a little less brutal option than disabling the whole ModemManaging.service
and in some cases can be a legit solution, for example when ModemManager mistakes some other device with a USB modem. People who use something like RS232 development boards are often stuck in this problem, so there’s even an official (tm) solution from the ModemManager author himself.
The downsides are mostly the same as in the previous option. Plus, I’d like to minimize the amount of tweaks I apply to the OS.
… by inhibiting the modem
Inhibiting the modem means taking it out of ModemManager temporarily. I’m not sure what is the intended use of this feature, but it’s quite simple (-m 0
is the index of the modem):
$ sudo mmcli -m 0 --inhibit
successfully inhibited device with uid '/sys/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.3'
type Ctrl+C to abort this program and remove the inhibition
This will temporarily take the modem out from ModemManager and we will be able to peacefully chat with it using /dev/ttyUSBx
.
For some time I was seriously considering this option because on one hand it doesn’t need any OS-level tweaks and on the other lets me reuse the legacy SMS sending script with minimum modifications.
What made me discard this option is the fact that after uninhibiting, the ID of the modem changes (increments). In principle, it’s not a big deal, but I felt uneasy about it. I’d like to run the control computer uninterruptible for as long as possible, but I’m not sure if this incremental ID consumes any system resource.
Another downside is that inhibiting requires admin rights (access to the tty device itself can be achieved by joining the dialout
group).
Workaround B (compromise): Sending Raw AT Commands through ModemManager
Turns out that it’s possible to send raw AT commands through ModemManager using mmcli
. This way we can simply replace printf "<at-command>" > /dev/ttyUSB2
from the example above with the mmcli -m 0 --command "<at-command>"
.
The downside is that this option works only when ModemManager works in the debug mode, i.e. we would need to create systemd unit file override and add there ExecStart=/usr/sbin/ModemManager --debug
. As I said earlier, I’d like to avoid tweaking OS as much as possible, but in principle, it can be an option. Also, it’s not a surprise, but this flag makes ModemManager shit tonnes of debug info into the systemd journal, so you might need to consciously consider journal storage and rotation policies if you end up using this in the production.
Sending SMS with ModemManager
Probably you already suspected that there should be an option to deal with the modem (including sending SMS and much more!) through the ModemManager itself. And you are right.
The official CLI to interact with the ModemManager is mmcil
. I need to send SMS from a bash script, so it’s good for me, but if you need to communicate with ModemManager from some other language (e.g. Python or C++) and you hate shelling out, there’s a D-Bus interface (it’s how mmcli
talks to ModemManager.servcie
) and some language wrappers.
But let’s focus on the mmcli
.
Sending SMS is a simple and straightforward process. First, we need to find out the ID of our modem:
$ mmcli --list-modems
/org/freedesktop/ModemManager1/Modem/0 [QUALCOMM INCORPORATED] QUECTEL Mobile Broadband Module
Btw, don’t rely on the fact that if you have only one modem, the ID is always 0.
By default mmcli
outputs information in the human-readable form that is not very convenient for parsing, but there’s a -J
option that makes mmcli
output data in JSON:
$ mmcli --list-modems -J
{"modem-list":["/org/freedesktop/ModemManager1/Modem/0"]}
For the sake of readability I’ll keep using human-readable form, but keep in mind that in the scripts I always use -J
.
After we figured out the ID of the modem we can query the information about it:
$ mmcli -m 0
----------------------------------
General | path: /org/freedesktop/ModemManager1/Modem/0
| device id: cafebabecafebabecafebabecafebabecafebabe
----------------------------------
Hardware | manufacturer: QUALCOMM INCORPORATED
| model: QUECTEL Mobile Broadband Module
| firmware revision: EC21ECGAR06A10M1G
| carrier config: default
| h/w revision: 10000
| supported: gsm-umts, lte
| current: gsm-umts, lte
| equipment id: 123456789101112
----------------------------------
System | device: /sys/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.3
| physdev: /sys/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.3
| drivers: option, qmi_wwan
| plugin: quectel
| primary port: cdc-wdm0
| ports: cdc-wdm0 (qmi), ttyUSB0 (ignored), ttyUSB1 (gps),
| ttyUSB2 (at), ttyUSB3 (at), wwan0 (net)
----------------------------------
Status | lock: sim-pin2
| unlock retries: sim-pin (3), sim-puk (10), sim-pin2 (3), sim-puk2 (10)
| state: registered
| power state: on
| access tech: lte
| signal quality: 100% (recent)
----------------------------------
Modes | supported: allowed: 2g; preferred: none
| allowed: 3g; preferred: none
| allowed: 4g; preferred: none
| allowed: 2g, 3g; preferred: 3g
| allowed: 2g, 3g; preferred: 2g
| allowed: 2g, 4g; preferred: 4g
| allowed: 2g, 4g; preferred: 2g
| allowed: 3g, 4g; preferred: 4g
| allowed: 3g, 4g; preferred: 3g
| allowed: 2g, 3g, 4g; preferred: 4g
| allowed: 2g, 3g, 4g; preferred: 3g
| allowed: 2g, 3g, 4g; preferred: 2g
| current: allowed: 2g, 3g, 4g; preferred: 4g
----------------------------------
Bands | supported: egsm, dcs, utran-1, utran-8, eutran-1, eutran-3, eutran-7,
| eutran-8, eutran-20, eutran-28
| current: egsm, dcs, utran-1, utran-8, eutran-1, eutran-3, eutran-7,
| eutran-8, eutran-20, eutran-28
----------------------------------
IP | supported: ipv4, ipv6, ipv4v6
----------------------------------
3GPP | imei: 123456789101112
| enabled locks: fixed-dialing
| operator id: 24412
| operator name: FI DNA
| registration: home
| packet service state: attached
----------------------------------
3GPP EPS | ue mode of operation: csps-2
| initial bearer path: /org/freedesktop/ModemManager1/Bearer/1
| initial bearer ip type: ipv4
----------------------------------
SIM | primary sim path: /org/freedesktop/ModemManager1/SIM/1
There’s a lot of information as you see, including signal strength, SIM lock status and operator name.
It’s quite interesting that in this mode I don’t need PIN to send SMS, despite the lock status being “fixed dialing”. Anyways, if you need PIN to unlock the SIM, it can be done by 2:
mmcli -m 0 --pin <PIN>
Sending SMS is a two steps process. First, we need to create SMS:
$ mmcli -m 0 --messaging-create-sms="text='Hellow from mmcli',number='+358123123123'"
Messaging | created sms: /org/freedesktop/ModemManager1/SMS/8
And then send it:
$ mmcli -s 8 --send
successfully sent the SMS
Result:
Conclusion
In the end, I’d like to note it’s not all rosy with using ModemManager. The ModemManager API does not cover 100% of what you can do through raw AT commands (although, the opposite is also true and not everything that is possible through ModemManager is possible through AT commands).
Even for such a simple task as sending an SMS, ModemManager is not always a 100% replacement. For example, sending SMS through AT commands does not put them into storage area before sending. May be storing the SMS before sending is a better way, but on the other hand you need to care about deleting sended SMS afterwards and this complicates the logic. But the most limiting is that mmcli
doesn’t allow creating SMS in PDU mode, the --messaging-create-sms
accepts only the text. So, if you want (or have to) work in the PDU mode, you might need to stick to one of the workarounds I’ve described before. But for the simple use cases MM is just perfect.