From 42990e4a3e6aaf440ffb2a45d1d393d398575b80 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 21 Apr 2026 15:45:01 +0200 Subject: [PATCH 1/2] Fix SMPP SMS encoding for non-ASCII characters Switch to two-argument setShortMessage(value, encoding) and resolve the Java charset from the configured data_coding, covering all 13 DCS values exposed in the UI. The data_coding field in the SubmitSM PDU and the encoding used for byte conversion are now driven by the same value. --- .../service/sms/smpp/SmppSmsSender.java | 26 +++++- .../service/sms/smpp/SmppSmsSenderTest.java | 86 +++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java b/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java index edd7ce80d6..1140ca86ea 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java @@ -85,8 +85,9 @@ public class SmppSmsSender extends AbstractSmsSender { request.setSourceAddr(new Address(config.getSourceTon(), config.getSourceNpi(), config.getSourceAddress())); } request.setDestAddr(new Address(config.getDestinationTon(), config.getDestinationNpi(), prepareNumber(numberTo))); - request.setShortMessage(message); - request.setDataCoding(Optional.ofNullable(config.getCodingScheme()).orElse((byte) 0)); + byte dataCoding = Optional.ofNullable(config.getCodingScheme()).orElse((byte) 0); + request.setShortMessage(message, charsetFor(dataCoding)); + request.setDataCoding(dataCoding); request.setReplaceIfPresentFlag((byte) 0); request.setEsmClass((byte) 0); request.setProtocolId((byte) 0); @@ -104,6 +105,27 @@ public class SmppSmsSender extends AbstractSmsSender { return countMessageSegments(message); } + private static String charsetFor(byte dataCoding) { + return switch (dataCoding) { + case 0 -> "US-ASCII"; // SMSC Default (GSM 7-bit subset) + case 1 -> "US-ASCII"; // IA5 + // DCS 2, 4 are "Octet unspecified" (binary) — ISO-8859-1 is bijective for bytes 0-255, so it passes bytes through unchanged. + case 2 -> "ISO-8859-1"; // Octet unspecified (binary pass-through) + case 3 -> "ISO-8859-1"; // Latin 1 + case 4 -> "ISO-8859-1"; // Octet unspecified (binary pass-through) + case 5 -> "Shift_JIS"; // JIS + case 6 -> "ISO-8859-5"; // Cyrillic + case 7 -> "ISO-8859-8"; // Latin/Hebrew + case 8 -> "UTF-16BE"; // UCS-2 + // FIXME: Pictogram encoding not natively supported, falling back to ISO-8859-1. + case 9 -> "ISO-8859-1"; // Pictogram + case 10 -> "ISO-2022-JP"; // Music Codes + case 13 -> "JIS_X0212-1990"; // Extended Kanji JIS + case 14 -> "EUC-KR"; // KS C 5601 + default -> throw new UnsupportedOperationException("Unsupported data_coding: " + dataCoding); + }; + } + private synchronized void checkSmppSession() { if (smppSession == null || !smppSession.isOpened()) { smppSession = initSmppSession(); diff --git a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java index 64145c24f8..4825b0c538 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java @@ -18,8 +18,10 @@ package org.thingsboard.server.service.sms.smpp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.smpp.Session; +import org.smpp.pdu.SubmitSM; import org.smpp.pdu.SubmitSMResp; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.sms.config.SmppSmsProviderConfiguration; @@ -27,6 +29,8 @@ import org.thingsboard.server.common.data.sms.config.SmppSmsProviderConfiguratio import java.lang.reflect.Constructor; import static org.junit.Assert.fail; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; @@ -91,6 +95,88 @@ public class SmppSmsSenderTest { })); } + @Test + public void testSendSms_dcs8_ucs2() throws Exception { + assertEncoded((byte) 8, "Привіт", "UTF-16BE"); + } + + @Test + public void testSendSms_dcs6_cyrillic() throws Exception { + assertEncoded((byte) 6, "Привіт", "ISO-8859-5"); + } + + @Test + public void testSendSms_dcs3_latin1() throws Exception { + assertEncoded((byte) 3, "naïve café", "ISO-8859-1"); + } + + @Test + public void testSendSms_dcs0_ascii() throws Exception { + assertEncoded((byte) 0, "Hello", "US-ASCII"); + } + + @Test + public void testSendSms_dcs1_ia5() throws Exception { + assertEncoded((byte) 1, "Hi", "US-ASCII"); + } + + @Test + public void testSendSms_dcs2_octet() throws Exception { + assertEncoded((byte) 2, "café", "ISO-8859-1"); + } + + @Test + public void testSendSms_dcs4_octet() throws Exception { + assertEncoded((byte) 4, "café", "ISO-8859-1"); + } + + @Test + public void testSendSms_dcs5_jis() throws Exception { + assertEncoded((byte) 5, "こんにちは", "Shift_JIS"); + } + + @Test + public void testSendSms_dcs7_hebrew() throws Exception { + assertEncoded((byte) 7, "שלום", "ISO-8859-8"); + } + + @Test + public void testSendSms_dcs9_pictogram() throws Exception { + assertEncoded((byte) 9, "café", "ISO-8859-1"); + } + + @Test + public void testSendSms_dcs10_music() throws Exception { + assertEncoded((byte) 10, "こんにちは", "ISO-2022-JP"); + } + + @Test + public void testSendSms_dcs13_extendedKanji() throws Exception { + // "Hi" triggers substitute bytes in JIS_X0212 (no ASCII mapping), which distinguishes it from a US-ASCII fallback. + assertEncoded((byte) 13, "Hi", "JIS_X0212-1990"); + } + + @Test + public void testSendSms_dcs14_korean() throws Exception { + assertEncoded((byte) 14, "안녕하세요", "EUC-KR"); + } + + private void assertEncoded(byte dataCoding, String message, String expectedCharset) throws Exception { + when(smppSession.isOpened()).thenReturn(true); + when(smppSession.submit(any())).thenReturn(new SubmitSMResp()); + setDefaultSmppConfig(); + smppConfig.setCodingScheme(dataCoding); + + smppSmsSender.sendSms("123545", message); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SubmitSM.class); + verify(smppSession).submit(captor.capture()); + SubmitSM sent = captor.getValue(); + + assertArrayEquals(message.getBytes(expectedCharset), sent.getShortMessageData().getBuffer()); + assertEquals(dataCoding, sent.getDataCoding()); + } + private void setDefaultSmppConfig() { smppConfig.setProtocolVersion("3.3"); smppConfig.setHost("smpphost"); From 98cbd340b5a2353c2629b29aef3a6a6c8520f502 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 9 Jun 2026 15:36:03 +0200 Subject: [PATCH 2/2] Encode SMPP default data_coding as GSM 7-bit and harden encoding fallback Map data_coding 0 (SMSC default alphabet) to the GSM 7-bit charset instead of US-ASCII, so GSM-alphabet characters are no longer lost to '?'. Fall back to ISO-8859-1 for unrecognized coding schemes instead of failing the send, and wrap send failures in SmsSendException so over-length and encoding errors surface clearly. Assert exact wire bytes for the UCS-2, Cyrillic and GSM 7-bit cases. --- .../service/sms/smpp/SmppSmsSender.java | 15 +++++--- .../service/sms/smpp/SmppSmsSenderTest.java | 35 ++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java b/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java index 1140ca86ea..0297dd31a2 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java @@ -33,6 +33,7 @@ import org.smpp.pdu.PDUException; import org.smpp.pdu.SubmitSM; import org.smpp.pdu.SubmitSMResp; import org.thingsboard.rule.engine.api.sms.exception.SmsException; +import org.thingsboard.rule.engine.api.sms.exception.SmsSendException; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.sms.config.SmppSmsProviderConfiguration; import org.thingsboard.server.service.sms.AbstractSmsSender; @@ -99,7 +100,7 @@ public class SmppSmsSender extends AbstractSmsSender { log.debug("SMPP submit command status: {}", response.getCommandStatus()); } catch (Exception e) { - throw new RuntimeException(e); + throw new SmsSendException("Failed to send SMS message - " + ExceptionUtils.getRootCauseMessage(e), e); } return countMessageSegments(message); @@ -107,8 +108,8 @@ public class SmppSmsSender extends AbstractSmsSender { private static String charsetFor(byte dataCoding) { return switch (dataCoding) { - case 0 -> "US-ASCII"; // SMSC Default (GSM 7-bit subset) - case 1 -> "US-ASCII"; // IA5 + case 0 -> Data.ENC_GSM7BIT; // SMSC Default Alphabet (GSM 7-bit) — opensmpp-charset SPI charset "X-Gsm7Bit" + case 1 -> "US-ASCII"; // IA5 (CCITT T.50) / ASCII // DCS 2, 4 are "Octet unspecified" (binary) — ISO-8859-1 is bijective for bytes 0-255, so it passes bytes through unchanged. case 2 -> "ISO-8859-1"; // Octet unspecified (binary pass-through) case 3 -> "ISO-8859-1"; // Latin 1 @@ -122,7 +123,13 @@ public class SmppSmsSender extends AbstractSmsSender { case 10 -> "ISO-2022-JP"; // Music Codes case 13 -> "JIS_X0212-1990"; // Extended Kanji JIS case 14 -> "EUC-KR"; // KS C 5601 - default -> throw new UnsupportedOperationException("Unsupported data_coding: " + dataCoding); + default -> { + // Unknown/unsupported data_coding (the @Schema allowableValues are documentation only and not enforced, + // so out-of-range values may be persisted). Fall back to a byte-transparent charset instead of failing the + // send, since SMS backs 2FA and notifications. ISO-8859-1 is bijective for bytes 0-255 and never substitutes. + log.warn("Unsupported SMPP data_coding {}, falling back to ISO-8859-1 encoding", dataCoding); + yield "ISO-8859-1"; + } }; } diff --git a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java index 4825b0c538..9f75842ced 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java @@ -97,12 +97,16 @@ public class SmppSmsSenderTest { @Test public void testSendSms_dcs8_ucs2() throws Exception { - assertEncoded((byte) 8, "Привіт", "UTF-16BE"); + // "Привіт" as UCS-2 (UTF-16BE) — exact wire bytes, two octets per code point. + byte[] expected = {0x04, 0x1F, 0x04, 0x40, 0x04, 0x38, 0x04, 0x32, 0x04, 0x56, 0x04, 0x42}; + assertEncodedBytes((byte) 8, "Привіт", expected); } @Test public void testSendSms_dcs6_cyrillic() throws Exception { - assertEncoded((byte) 6, "Привіт", "ISO-8859-5"); + // "Привіт" as ISO-8859-5 — exact single-byte Cyrillic wire bytes. + byte[] expected = {(byte) 0xBF, (byte) 0xE0, (byte) 0xD8, (byte) 0xD2, (byte) 0xF6, (byte) 0xE2}; + assertEncodedBytes((byte) 6, "Привіт", expected); } @Test @@ -111,8 +115,11 @@ public class SmppSmsSenderTest { } @Test - public void testSendSms_dcs0_ascii() throws Exception { - assertEncoded((byte) 0, "Hello", "US-ASCII"); + public void testSendSms_dcs0_gsm7() throws Exception { + // DCS 0 is the SMSC default alphabet (GSM 7-bit). 'é' is in the GSM alphabet (0x05); under a plain US-ASCII + // fallback it would be lost to '?' (0x3F), so these bytes guard against that regression. + byte[] expected = {0x63, 0x61, 0x66, 0x05}; // café + assertEncodedBytes((byte) 0, "café", expected); } @Test @@ -161,7 +168,22 @@ public class SmppSmsSenderTest { assertEncoded((byte) 14, "안녕하세요", "EUC-KR"); } + // Verifies the DCS -> charset mapping. The expected side mirrors the production charset, so it locks the mapping + // but not the exact wire bytes; use assertEncodedBytes for charsets where a silent substitution could hide a bug. private void assertEncoded(byte dataCoding, String message, String expectedCharset) throws Exception { + SubmitSM sent = captureSentPdu(dataCoding, message); + assertArrayEquals(message.getBytes(expectedCharset), sent.getShortMessageData().getBuffer()); + assertEquals(dataCoding, sent.getDataCoding()); + } + + // Locks the exact bytes written into the SubmitSM PDU against a known-good sequence (independent of the production code). + private void assertEncodedBytes(byte dataCoding, String message, byte[] expectedBytes) throws Exception { + SubmitSM sent = captureSentPdu(dataCoding, message); + assertArrayEquals(expectedBytes, sent.getShortMessageData().getBuffer()); + assertEquals(dataCoding, sent.getDataCoding()); + } + + private SubmitSM captureSentPdu(byte dataCoding, String message) throws Exception { when(smppSession.isOpened()).thenReturn(true); when(smppSession.submit(any())).thenReturn(new SubmitSMResp()); setDefaultSmppConfig(); @@ -171,10 +193,7 @@ public class SmppSmsSenderTest { ArgumentCaptor captor = ArgumentCaptor.forClass(SubmitSM.class); verify(smppSession).submit(captor.capture()); - SubmitSM sent = captor.getValue(); - - assertArrayEquals(message.getBytes(expectedCharset), sent.getShortMessageData().getBuffer()); - assertEquals(dataCoding, sent.getDataCoding()); + return captor.getValue(); } private void setDefaultSmppConfig() {