Eight command line tools for exercising a running SMPPSim instance over real SMPP, so it can be checked without installing an SMPP client. All of them are plain bash over /dev/tcp — no client library, no Java, nothing to install.
They share the SMPP wire layer in smpp_lib.sh, which lives here too. Keep the directory intact: every script sources the library from beside itself, which is also why the scripts can be invoked from any working directory, by an absolute path if you like.
| Option | Meaning | Default |
|---|---|---|
-H |
host | 127.0.0.1 |
-P |
SMPP port | 2775 |
-i |
system_id |
smppclient1 |
-w |
password | password |
-t |
response timeout in seconds | 10 |
-h |
show help |
send_deliver_sm.sh stretches two of them: its -H is also the host of the web interface it injects through, and its -t is how long to wait for a DELIVER_SM rather than for a response, defaulting to 60 seconds. The table under each script lists only the options that script adds.
send_submit_sm.sh submits a single message to a running simulator, which is the quickest way to confirm that it really speaks SMPP and not just that the port is open. It performs bind_transmitter, sends one submit_sm, prints the message_id the SMSC assigned and unbinds.
./send_submit_sm.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
submit_sm_resp: ESME_ROK, message_id=0
unbound
Override what you need:
./send_submit_sm.sh -s 79001234567 -d 79007654321 -m "Hello there" -r 1| Option | Meaning | Default |
|---|---|---|
-s |
source_addr |
12345 |
-d |
destination_addr |
447700900000 |
-m |
short_message |
a fixed test string |
-c |
data_coding |
0 |
-r |
registered_delivery, 1 asks for a delivery receipt |
0 |
The exit status distinguishes the failure modes, so the script also works as a smoke test in CI: 0 accepted, 1 bad usage, 2 connection failed, 3 bind rejected, 4 submit rejected. A rejected PDU is reported with its SMPP status name, for example bind_transmitter_resp: ESME_RINVPASWD (0x0000000e).
Both addresses are sent with TON/NPI 1/1 (international/ISDN); change ADDR_TON / ADDR_NPI at the top of the script if you need something else. short_message is limited to 254 bytes, the maximum that fits in sm_length — note that the limit is on bytes, so a UTF-8 message runs out sooner than its character count suggests.
DELIVER_SM travels from the SMSC to the ESME, so a client cannot simply transmit one: SMPPSim has no case for a client originated DELIVER_SM and answers such a PDU with GENERIC_NAK. send_deliver_sm.sh therefore drives the whole exchange. It binds as a receiver, asks the simulator to originate an MO message through the injection endpoint of the web interface, prints the DELIVER_SM that arrives and acknowledges it with DELIVER_SM_RESP.
./send_deliver_sm.shconnected to 127.0.0.1:2775
bound as receiver, address_range=12345, smsc system_id=SMPPSim
injected MO 447700900000 -> 12345 via http://127.0.0.1:8088/inject_mo
waiting up to 60s for 1 deliver_sm...
deliver_sm #1: 447700900000 -> 12345, esm_class=0, data_coding=0, 36 bytes
short_message: Test message from send_deliver_sm.sh
received 1 deliver_sm, unbound
With -l it skips the injection and only listens, which is how to watch delivery receipts, MO service traffic or LOOPBACK messages. A receipt is recognised and labelled:
./send_deliver_sm.sh -l # in one shell
./send_submit_sm.sh -r 1 -s 12345 # in anotherdeliver_sm #1 (delivery receipt): 447700900000 -> 12345, esm_class=4, data_coding=0, 108 bytes
short_message: id:1 sub:001 dlvrd:001 submit date:2609131522 done date:2609131522 stat:DELIVRD err:000 Text:receipt probe 2
message_state: DELIVERED (2)
For a receipt the script also prints the message_state it reports. On a terminal the state is coloured: green once the message is DELIVERED, red for the states that mean it never arrived — EXPIRED, DELETED, UNDELIVERABLE, UNKNOWN and REJECTED. The value is read from the message_state optional parameter (tag 0x0427) that SMPPSim attaches to receipts for clients bound as SMPP 3.4 or later; if that parameter is missing, because DELIVERY_RECEIPT_OPTIONAL_PARAMS is off, the stat: field of the receipt text is used instead.
| Option | Meaning | Default |
|---|---|---|
-W |
web interface port, used for the injection | 8088 |
-s |
source_addr of the injected MO |
447700900000 |
-d |
destination_addr of the injected MO |
12345 |
-m |
short_message of the injected MO |
a fixed test string |
-c |
data_coding of the injected MO |
0 |
-a |
address_range for the bind |
the -d address, or .* with -l |
-n |
how many DELIVER_SM to wait for | 1 |
-l |
listen only, do not inject | off |
-t |
how long to wait for a DELIVER_SM | 60 |
-C |
colour the state: auto, always or never |
auto |
Exit status: 0 received, 1 bad usage, 2 connection failed, 3 bind rejected, 4 injection failed, 5 nothing delivered before the timeout.
A delivery receipt is not immediate: it is only queued once the life cycle simulator moves the message to a final state, which can take considerably longer than the sweep interval suggests. If no receiver is bound at that moment the receipt waits in the pending queue and is handed over as soon as one binds, so a run that timed out often shows the backlog on the next attempt. Sixty seconds is the default for that reason; raise it with -t on a busy queue.
auto colours only when standard output is a terminal and NO_COLOR is unset, so a redirected or piped run stays free of escape codes.
The address_range given at bind time decides which messages the SMSC routes to this session: it is a regular expression and the destination address is searched for it, so the default of using the destination address verbatim always matches. See Fixes for why that is a search rather than a whole-string match.
send_submit_multi.sh sends a single SUBMIT_MULTI carrying a list of destinations and reports the message_id together with the destinations the SMSC refused.
./send_submit_multi.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
submit_multi_resp: ESME_ROK, message_id=0, 3 destinations, 0 refused
unbound
Destinations come from -D as a comma separated list. An entry prefixed with dl: is sent as a distribution list name rather than an address, which is the second form the PDU allows:
./send_submit_multi.sh -D "79001112233,dl:friends" -m "Hello everyone"The standard protocol handler accepts every destination, so the refused list stays empty. TestProtocolHandler1 refuses anything that is not numeric, so pointing the script at the instance that runs it — conf/props.test1 on port 2776, see Testing — exercises the other half of the response:
./send_submit_multi.sh -P 2776 -i smppclient -D "447700900001,not-a-number,447700900003"submit_multi_resp: ESME_ROK, message_id=0, 3 destinations, 1 refused
refused not-a-number: ESME_RINVDSTADR (0x0000000b)
| Option | Meaning | Default |
|---|---|---|
-s |
source_addr |
12345 |
-D |
comma separated destinations, dl: for a distribution list name |
three test addresses |
-m |
short_message |
a fixed test string |
-c |
data_coding |
0 |
-r |
registered_delivery, 1 asks for a delivery receipt |
0 |
Exit status: 0 all destinations accepted, 1 bad usage, 2 connection failed, 3 bind rejected, 4 submit_multi rejected, 5 accepted but some destinations were refused. That last one is the reason the script is useful in CI: a partial failure is reported in the body of an otherwise successful response, and nothing else in the exchange gives it away.
number_of_dests is a single byte, so at most 255 destinations fit in one PDU; the script refuses a longer list rather than sending a malformed one.
DATA_SM is the leaner alternative to SUBMIT_SM. It has no short_message field at all: the text travels in the message_payload optional parameter, whose length field is two bytes wide, so the 254 byte ceiling of sm_length does not apply.
./send_data_sm.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
data_sm_resp: ESME_ROK, message_id=0, message_payload=33 bytes
verify: query_sm answers ESME_RQUERYFAIL, as expected for a DATA_SM
unbound
A message that send_submit_sm.sh refuses to send goes through here unchanged:
./send_data_sm.sh -m "$(head -c 500 /dev/zero | tr '\0' x)"data_sm_resp: ESME_ROK, message_id=1, message_payload=500 bytes
| Option | Meaning | Default |
|---|---|---|
-s |
source_addr |
12345 |
-d |
destination_addr |
447700900000 |
-m |
the message, sent as the message_payload TLV |
a fixed test string |
-c |
data_coding |
0 |
-e |
esm_class |
0 |
-r |
registered_delivery |
0 |
Exit status: 0 accepted, 1 bad usage, 2 connection failed, 3 bind rejected, 4 data_sm rejected.
The closing query is not a formality. SMPPSim never puts a DATA_SM in its outbound queue — the code that would do it is commented out — so such a message has no life cycle, produces no delivery receipt and cannot be queried, cancelled or replaced. The script asks anyway and reports the ESME_RQUERYFAIL that proves it, which is the one observable difference from send_submit_sm.sh.
The simulator does validate the addresses: an empty -s is answered with ESME_RINVSRCADR and an empty -d with ESME_RINVDSTADR, both counted as a rejected submission.
With LOOPBACK enabled the simulator sends the message back to a bound receiver as another DATA_SM, addresses swapped, rather than converting it to a DELIVER_SM. send_deliver_sm.sh only acknowledges DELIVER_SM, so it reports such a PDU as ignoring PDU with command_id 0x00000103.
send_query_sm.sh submits a message and then asks the SMSC what became of it with QUERY_SM, which is the way to watch the life cycle simulator from the outside.
./send_query_sm.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
submitted, message_id=0
query_sm_resp: message_id=0, state=ENROUTE (1), error_code=0, final_date=
unbound
What you get back is not deterministic: the outbound queue is only swept every MESSAGE_STATE_CHECK_FREQUENCY milliseconds, and the state a message lands in is drawn at random from the PERCENTAGE_* settings. A query right after the submit therefore usually still says ENROUTE.
With -p the script keeps querying until the message leaves ENROUTE, so the transition itself becomes visible:
./send_query_sm.sh -p -e 1submitted, message_id=1
query_sm_resp: message_id=1, state=ENROUTE (1), error_code=0, final_date=
query_sm_resp: message_id=1, state=ACCEPTED (6), error_code=0, final_date=260913150537000+
-M asks about a message you already have an id for, without submitting a new one:
./send_query_sm.sh -M 0| Option | Meaning | Default |
|---|---|---|
-s |
source_addr, of both the submit and the query |
12345 |
-d |
destination_addr of the submit |
447700900000 |
-m |
short_message of the submit |
a fixed test string |
-M |
query this message_id instead of submitting |
|
-p |
keep querying until the state is final | off |
-e |
interval between polls | 2 |
-T |
how long to keep polling | 60 |
Exit status: 0 answered, 1 bad usage, 2 connection failed, 3 bind rejected, 4 submit rejected, 5 query rejected.
Two things make a query fail with ESME_RQUERYFAIL. The SMSC matches on the message_id together with the source address and its TON/NPI, so a query has to repeat the source address of the original submission — -s sets it for both, but -M with the wrong -s will not find the message. And the state is only kept for DISCARD_FROM_QUEUE_AFTER milliseconds, 60 seconds by default, after which the message is forgotten and the query fails too.
send_cancel_sm.sh submits a message, cancels it with CANCEL_SM and then confirms the cancel actually took effect. That last step is worth having, because CANCEL_SM_RESP carries no body: the only way to see the difference is that the SMSC has dropped the message from its outbound queue and no longer answers a query about it.
./send_cancel_sm.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
submitted, message_id=0
cancel_sm_resp: ESME_ROK, cancelled message_id=0
verify: query_sm now answers ESME_RQUERYFAIL, the message is gone
unbound
-M cancels a message you already have an id for. -A leaves the message_id field empty, which SMPP defines as "cancel everything queued between these two addresses"; -S narrows that to one service_type:
./send_cancel_sm.sh -M 3 # one message
./send_cancel_sm.sh -A -s 55555 -d 66666 # every message between the two| Option | Meaning | Default |
|---|---|---|
-s |
source_addr, of both the submit and the cancel |
12345 |
-d |
destination_addr |
447700900000 |
-m |
short_message of the submit |
a fixed test string |
-M |
cancel this message_id instead of submitting |
|
-A |
cancel every queued message between -s and -d |
off |
-S |
service_type, narrows -A |
empty |
Exit status: 0 cancelled, 1 bad usage, 2 connection failed, 3 bind rejected, 4 submit rejected, 5 cancel rejected.
A single cancel is matched the same way as a query — on message_id plus the source address and its TON/NPI — so it fails with ESME_RCANCELFAIL for an unknown id, for the right id with the wrong -s, and for a message the SMSC has already discarded after DISCARD_FROM_QUEUE_AFTER. The bulk form reports success even when it matched nothing; the count only appears in the simulator's own log.
send_replace_sm.sh submits a message, replaces its text with REPLACE_SM and then queries it to show it is still queued afterwards.
./send_replace_sm.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
submitted, message_id=0, text=Original text from send_replace_sm.sh
replace_sm_resp: ESME_ROK, message_id=0 now carries 37 bytes: Replaced text from send_replace_sm.sh
verify: the message is still queued, state=ENROUTE
unbound
REPLACE_SM_RESP has no body and QUERY_SM does not return the text, so the query only proves the message survived. To see the replacement itself, store it with registered_delivery set and read the Text: field of the receipt, which carries the first twenty bytes of whatever the message says at that point:
./send_deliver_sm.sh -l # in one shell
./send_replace_sm.sh -m "BEFORE the replace" -N "AFTER the replace" -r 1 short_message: id:1 sub:001 ... stat:UNDELIV err:000 Text:AFTER the replace
That also shows what -r on a replace is good for: the original submission asked for no receipt, and the replacement turned one on.
| Option | Meaning | Default |
|---|---|---|
-s |
source_addr, of both the submit and the replace |
12345 |
-d |
destination_addr of the submit |
447700900000 |
-m |
short_message of the submit |
a fixed test string |
-N |
the replacement short_message |
a fixed test string |
-M |
replace this message_id instead of submitting |
|
-r |
registered_delivery to store with the replacement |
0 |
Exit status: 0 replaced, 1 bad usage, 2 connection failed, 3 bind rejected, 4 submit rejected, 5 replace rejected.
The replace is matched exactly like a query or a cancel, so ESME_RREPLACEFAIL means the same three things: unknown id, right id with the wrong -s, or a message the SMSC has already discarded after DISCARD_FROM_QUEUE_AFTER. REPLACE_SM also carries schedule_delivery_time and validity_period; this script sends both empty and changes only the text and registered_delivery.
ENQUIRE_LINK is the SMPP keepalive: an empty PDU whose only job is to prove the session is still alive. send_enquire_link.sh binds, sends a few of them, times each answer and prints a summary.
./send_enquire_link.shconnected to 127.0.0.1:2775
bound as smppclient1, smsc system_id=SMPPSim
enquire_link #1: ESME_ROK, seq=2, 8.7 ms
enquire_link #2: ESME_ROK, seq=3, 13.1 ms
enquire_link #3: ESME_ROK, seq=4, 8.9 ms
3 sent, 3 answered, 0 lost; min/avg/max 8.7/10.2/13.1 ms
unbound
| Option | Meaning | Default |
|---|---|---|
-n |
how many ENQUIRE_LINK to send | 3 |
-e |
interval between them in seconds | 1 |
Exit status: 0 every ping answered, 1 bad usage, 2 connection failed, 3 bind rejected, 5 at least one ping lost or refused.
Read the timings as a health check, not as a benchmark. They cover the script's own read path — a timeout, a dd and two more processes per PDU — so single digit milliseconds on a loopback connection is the floor, and what matters is a figure that suddenly grows or a ping that never comes back.
The script binds before pinging because SMPPSim answers ENQUIRE_LINK on an unbound session with ESME_RINVBNDSTS. Any bind type would do; this one uses a transmitter like the rest of the scripts.
Two properties on the simulator side make the answers late or absent on purpose, which is what turns this script from a health check into a test of the client's own keepalive handling — see Configuration:
| Property | Effect | Default |
|---|---|---|
ENQUIRE_LINK_RESPONSE_DELAY |
hold every ENQUIRE_LINK_RESP for this many ms | 0 |
DROP_ENQUIRE_LINK_RESPONSES |
never answer ENQUIRE_LINK at all; takes precedence over the delay | false |
With ENQUIRE_LINK_RESPONSE_DELAY=3000 the round trip times report the delay itself:
./send_enquire_link.sh -n 2enquire_link #1: ESME_ROK, seq=2, 3016.6 ms
enquire_link #2: ESME_ROK, seq=3, 3021.4 ms
2 sent, 2 answered, 0 lost; min/avg/max 3016.6/3019.0/3021.4 ms
Give the script a -t shorter than the delay, or set DROP_ENQUIRE_LINK_RESPONSES=true, and the pings are counted as lost and the script exits with status 5:
./send_enquire_link.sh -n 1 -t 1enquire_link #1: no answer within 1s
1 sent, none answered
The delay occupies the connection handler thread of that session, so the simulator reads nothing else from this client while it lasts — which is exactly the busy SMSC being simulated. Dropped answers are counted as enquire_link_err on the statistics page, since nothing was answered OK.
smpp_lib.sh implements the SMPP wire format the scripts are built on: assembling and sending PDUs, reading responses and walking a PDU body. It is a library, not a program — source it, do not run it. The scripts source it relative to their own location rather than the working directory, so they can be invoked by an absolute path from anywhere, as long as the library stays beside them.
Everything is computed on hex strings and turned into bytes only on the way out to the socket, because shell variables cannot hold the NUL bytes an SMPP frame is full of.
What the library expects from the script that sources it:
| Name | Meaning |
|---|---|
| fd 3 | an open read/write socket to the SMSC |
$TIMEOUT |
seconds read_pdu waits before giving up |
$SEQ |
sequence number counter, initialised by the library |
What it provides:
| Function | Purpose |
|---|---|
hex_of, hex_cstr |
a string as hex, plain or as a C-Octet String |
hex_u8, hex_u32, hex_bytes |
integer fields and the byte count of a hex string |
hex_to_bin, hex_to_text |
hex back to bytes, or to a printable string |
send_pdu, send_pdu_seq |
frame a PDU and write it to fd 3, with the next or a given sequence number |
read_pdu |
read one PDU into RESP_ID, RESP_STATUS, RESP_SEQ, RESP_BODY; returns 1 on timeout |
take, take_cstr |
cursor over $BODY, each call leaving the field in $TAKEN |
command_status_name |
the ESME_* name for a command_status |
message_state_name, message_state_is_final |
the name of a message_state, and whether it can still change |
urlencode, die |
percent encoding, and exit with a message |
That is enough to write a client of your own. A session that binds, pings and unbinds:
#! /bin/bash
set -u
. "$(cd "$(dirname "$0")" && pwd)/smpp_lib.sh"
TIMEOUT=10
exec 3<>/dev/tcp/127.0.0.1/2775
# BIND_TRANSMITTER: system_id, password, system_type, then
# interface_version 0x34, addr_ton, addr_npi and an empty address_range.
send_pdu 00000002 "$(hex_cstr smppclient1)$(hex_cstr password)$(hex_cstr '')34000000"
read_pdu && printf 'bind: %s\n' "$(command_status_name "$RESP_STATUS")"
send_pdu 00000015 "" # ENQUIRE_LINK
read_pdu && printf 'enquire_link: %s\n' "$(command_status_name "$RESP_STATUS")"
send_pdu 00000006 "" # UNBIND
read_pdu
exec 3>&-bind: ESME_ROK
enquire_link: ESME_ROK