Я понимаю, что в MikroTik рассматривают SMS как способ управления роутером через команды, состоящие из цифр и латиницы. Но мне показалось, что это неплохая идея для пет-проекта в виде Android-приложения. Иногда в SMS может прийти и полезная информация: о подорожании тарифа или о том, что зерги идут в 6-pool раш. О том, как я склеиваю SMS и декодирую текст в своём приложении, и пойдёт речь. Для чтения сообщений я использую два варианта, но опишу только второй:
Подключаюсь к роутеру по API, используя библиотеку mikrotik-java
Получаю SMS от роутера по email в виде текстового файла, используя скрипт.
Начиная с RouterOS версии 7.15, мы можем получить от роутера полные данные об SMS, включая PDU, который содержит в себе всю информацию о сообщении.
/tool/sms/inbox/print detail
В консоли роутера ответ для каждой SMS будет представлен в следующем формате:
source="usb2,0" phone="+79011234567" type=class-0 timestamp="2026-03-09 21:30:51+03:00" message="some text two words support" pdu="07919761980644F0040B919720481784F50000623090120315211BF377BB0CA297F17410FDFE06DDDF72F21C34AFC3E16F391D"
Скрипт записывает этот ответ роутера в строку и присылает по email в следующем виде:
{"phone":"+79011234567","timestamp":"2026-03-09 21:30:51+03:00","message":"some text two words support","pdu":"07919761980644F0040B919720481784F50000623090120315211BF377BB0CA297F17410FDFE06DDDF72F21C34AFC3E16F391D","source":"usb2,0","type":"class-0"}
Далее мы читаем файл и определяем количество сообщений. Мы знаем, что каждая запись начинается с ключа {"phone": и заканчивается либо перед следующей записью, либо в конце файла. Мы просто парсим все значения из каждой SMS, создавая на их основе объекты класса Sms.
private static final String GSM_ALPHABET = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\u001BÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"; private final String phone; private final String timestamp; private final String message; private final String source; private String type; private String pdu; private String decodeMessage = AppController.EMPTY_STRING; private int udh; public Sms(String phone, String timestamp, String message, String pdu, String source, String type) { this.phone = phone == null ? AppController.EMPTY_STRING : phone.trim(); this.timestamp = timestamp == null ? AppController.EMPTY_STRING : timestamp.trim(); this.message = message == null ? AppController.EMPTY_STRING : message; this.pdu = pdu == null ? AppController.EMPTY_STRING : pdu; this.source = source == null ? AppController.EMPTY_STRING : source.trim(); this.type = type == null ? AppController.EMPTY_STRING : type; }
Наш объект Sms это эквивалент одного сообщения в роутере. Пора разобрать его PDU, чтобы понять, в какой кодировке оно пришло и является ли оно самостоятельным сообщением или частью длинного. Разбор будет в одном большом методе, состоящем из десяти шагов.
Шаг 0: Подготовка данных
На практике PDU как отдельного поля в данных из роутера может и не быть, иногда оно содержится прямо в теле сообщения. Поэтому сначала проверяем, не является ли поле pdu пустой строкой. Если оно пустое, ищем PDU внутри строки message, отрезая всё, что идет после подстроки pdu=.
Если PDU найден, фильтруем его: оставляем символы от начала строки и до первого не-HEX символа, отсекая лишний мусор (например, остаточные команды модема в конце). Также проверяем строку на чётность: если количество символов нечётно, отрезаем последний лишний знак. Из полученной корректной HEX-строки формируем массив байтов data.
private static byte[] hexStringPduToByteArray(@NonNull String pdu) { if (pdu.isEmpty()) return new byte[0]; pdu = pdu.replaceAll("(?s)[^0-9A-Fa-f].*", "").trim(); // режем всё что не hex // если нечётная длина, усекаем последний символ if (pdu.length() % 2 != 0) { pdu = pdu.substring(0, pdu.length() - 1); } int len = pdu.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(pdu.charAt(i), 16) << 4) + Character.digit(pdu.charAt(i + 1), 16)); } return data; }
Шаги 1–3: Чтение служебной информации
Создаём указатель по массиву index и проходим по нему, последовательно обращаясь ко всем параметрам PDU для вычисления их значений и длины. При каждом обращении к массиву мы инкрементируем index++, а перед следующим шагом выполняем проверку на выход за границы массива. Основные пояснения по структуре полей будут даны непосредственно в комментариях к коду.
public void decodePduToText() { if (pdu.isEmpty()) { // проверяем оригинальный pdu pdu = getPduFromMessage(message); // и тогда пробуем получить из message } if (pdu.isEmpty()) return; byte[] data = hexStringPduToByteArray(pdu); // отрезаем не hex хвост и получаем массив байт // Минимальный PDU содержит SMSC(1+7), FirstOctet(1), OA(2+), PID(1), DCS(1), SCTS(7), UDL(1) int index = 0; // указатель // 1. SMSC (Service Center Address) // Первый байт — длина номера сервис-центра в байтах. int smscLen = data[index++] & 0xFF; // переводим значения byte в int зануляя первые 24 бита, чтоб получить правильное положительное значение index += smscLen; if (index >= data.length) return; // 2. First Octet (Тип PDU) // Бит 6 (0x40) указывает на наличие User Data Header (UDHI). int firstOctet = data[index++] & 0xFF; // 3. Originating Address (Номер отправителя) if (index >= data.length) return; int addrLen = data[index++] & 0xFF; // Длина номера в ПОЛУ-октетах (цифрах) if (index >= data.length) return; int addrType = data[index++] & 0xFF; // Формат номера (International, National и т.д.) // Расчет байтов: на 1 байт приходится 2 цифры номера. +1 для учета нечетной длины. int addrBytes = (addrLen + 1) / 2; if (index + addrBytes > data.length) return; index += addrBytes;
На этом этапе у нас уже есть значение firstOctet, по которому мы понимаем, является ли SMS самостоятельным сообщением или частью длинного.
Шаги 4–7: Кодировка и длина данных
Получаем DCS (Data Coding Scheme), которая определяет тип кодировки сообщения, и UDL (User Data Length). Значение UDL, в зависимости от кодировки, может означать либо количество байтов, либо количество символов текста, включая заголовок пользовательских данных (UDH).
// 4. PID (Protocol Identifier) if (index >= data.length) return; index++; // Обычно 0x00 для стандартных SMS // 5. DCS (Data Coding Scheme) — КРИТИЧНО для декодирования // Определяет: 7-bit, 8-bit (binary) или 16-bit (UCS2/UTF-16). if (index >= data.length) return; int dcs = data[index++] & 0xFF; // 6. Timestamp (Service Centre Time Stamp) 7 байт в формате BCD (год, месяц, день, час, мин, сек, таймзона). index += 7; // 7. User Data Length (UDL) // Для 7-битной кодировки это кол-во СИМВОЛОВ, для 8/16-битной — кол-во БАЙТ. if (index >= data.length) return; int udl = data[index++] & 0xFF;
Шаг ∞: Бесконечный ад :)
На этом этапе мы определяем по значению firstOctet (полученному на шаге 3), является ли наша SMS частью составного сообщения. Создаём переменную shift это счётчик, который будет хранить количество септетов, занимаемых заголовком пользовательских данных (UDH), которое нам надо будет пропустить перед 7-bit декодированием текста.
udh = -1; // Сброс номера части (по умолчанию -1, если сообщение одиночное) int shift = 0; // 8. User Data Header (если есть) // 8. Разбор User Data Header (UDH) — если сообщение склееное if ((firstOctet & 0x40) != 0) { // UDHI = 1 это сообщение составное if (index >= data.length) return; int udhLen = data[index++] & 0xFF; // Длина всего заголовка UDH (минимум 5 байт) if (index + udhLen < data.length && udhLen >= 5) { int iei = data[index] & 0xFF; // Information Element Identifier Идентификатор элемента (0x00 или 0x08) int ieLen = data[index + 1] & 0xFF; // Длина данных элемента // Проверяем только стандартные типы UDH (8-bit и 16-bit reference) if ((iei == 0x00 && ieLen == 3) || (iei == 0x08 && ieLen == 4)) { // 0x00 = 8-bit , 0x08 = 16-bit // int ref = data[index + 2] & 0xFF; // Message reference // int total = data[index + 3] & 0xFF; // Total parts if (iei == 0x00) { udh = data[index + 4] & 0xFF; // Порядковый номер части (8-bit ссылка) } else { udh = data[index + 5] & 0xFF; // Порядковый номер части (16-bit ссылка) } } } if ((dcs & 0x0C) == 0x08 || (dcs & 0x0C) == 0x04) { //если кодировка UCS2 или 8-bit. // Пропускаем заголовок, сдвигаем индекс за пределы заголовка к началу самого текста смс. index += udhLen; udl -= (udhLen + 1); // UCS2, 8-bit: вычитаем байты (сам заголовок + байты длины) // в этот момент udl указывает на кол-во байт которое занимает только текст в смс } else { // ((dcs & 0x0C) == 0x00) (и зарезервированные 11xx) трактуем как 7-bit GSM // Если кодировка 7-битная, UDL включает в себя и UDH, выраженный в "септетах". index--; // возвращаем индекс на позицию udhLen из позиции iei shift = getUdhSeptetsCount(udhLen + 1); // получаем кол-во септетов для (udh + его длинна) // которые надо пропустить перед декодированием текста } }
private static int getUdhSeptetsCount(int udhLen) { // Переводим байты в биты и считаем, сколько септетов это занимает. return (udhLen * 8 + 6) / 7; }
На этом шаге, в случае если кодировка оказалась UCS2, наш index указывает на начало самого текста в сообщении, вне зависимости от того есть ли в смс UDH, то есть является ли она частью большой смс. В случае если кодировка 7-bit, если UDH отсутствует, index тоже указывает на начало самого текста, если UDH есть, он указывает на начало UDH, после которого идёт сам текст. UDH будет занимать некоторое кол-во (shift) септетов, а не байт.
Шаги 9–10: Не последние
В зависимости от кодировки, создаём строку, передав в конструктор, массив байт, позицию в массиве откуда начинается текст, длину текста и тип кодировки. Или вызываем метод декодирования 7-bit.
// 9. Извлечение полезной нагрузки (User Data) сам текст if (index >= data.length) return; int userDataLength = data.length - index; // тут userDataLength равен udl для UCS2 и 8-bit, и можно обойтись и без него, но мне нравиться так. // 10. Декодирование текста в зависимости от DCS из шага 5 try { if ((dcs & 0x0C) == 0x08) { // Проверка бит 2 и 3 в DCS: 10xx = UCS2 (UTF-16BE) decodeMessage = new String(data, index, userDataLength, StandardCharsets.UTF_16BE); } else if ((dcs & 0x0C) == 0x04) { // 01xx: 8-bit Data decodeMessage = new String(data, index, userDataLength, StandardCharsets.ISO_8859_1); } else { // ((dcs & 0x0C) == 0x00) 7-bit (00xx и зарезервированные 11xx) трактуем как 7-bit GSM decodeMessage = decode7bit(data, index, udl, shift); } } catch (Exception e) { decodeMessage = AppController.EMPTY_STRING; } }
Большую часть кода писали ИИ, и до 8-го шага всё работало отлично. Однако они жаловались, что большинство 7-bit декодеров в интернете спотыкаются на составных сообщениях. У них не было готового решения: они инициализировали shift = 0, но нигде его не применяли. Начиная с 8-го шага, пришлось включаться в процесс самому.
Метод decode7bit
Мы перебираем септеты в массиве байтов, пропуская количество септетов (shift), которое занимает UDH. Также заводим флаг nextIsExtension для обработки символов из расширенной таблицы (например, знака евро или фигурных скобок).
Ещё раз, мы проходим по 7-битным септетам, «запакованным» в 8-битные октеты, учитывая возможное битовое смещение и возможный разрыв одного символа между двумя соседними байтами.
.
@NonNull private static String decode7bit(byte[] data, int index, int udl, int shift) { StringBuilder sb = new StringBuilder(udl); boolean nextIsExtension = false; int currentBitPos = shift * 7; // Начальное смещение в битах for (int i = shift; i < udl; i++) { int bytePos = index + (currentBitPos / 8); int bitOffset = currentBitPos % 8; // Безопасная проверка границ массива if (bytePos >= data.length) break; // Извлекаем текущий байт int currentByte = data[bytePos] & 0xFF; // Получаем 7-битный код int charCode = (currentByte >>> bitOffset); // Если символ разбит между двумя байтами if (bitOffset > 1 && bytePos + 1 < data.length) { charCode |= (data[bytePos + 1] & 0xFF) << (8 - bitOffset); } charCode &= 0x7F; if (charCode == 0x1B) { // 0x1B (Escape) в протоколе GSM управляющая команда, // брать след символ в расширенной таблице nextIsExtension = true; } else { if (nextIsExtension) { sb.append(getExtChar(charCode)); nextIsExtension = false; } else { sb.append(GSM_ALPHABET.charAt(charCode)); } } // Инкремент позиции бит на 7 для следующего шага currentBitPos += 7; } return sb.toString(); } private static char getExtChar(int code) { return switch (code) { case 10 -> '\n'; case 20 -> '^'; case 40 -> '{'; case 41 -> '}'; case 47 -> '\\'; case 60 -> '['; case 61 -> '~'; case 62 -> ']'; case 64 -> '|'; case 101 -> '€'; default -> GSM_ALPHABET.charAt(code); // Если символ в расширении не найден, возвращаем из основной таблицы }; }
Теперь в наших объектах Sms заполнены все необходимые поля: decodedMessage (раскодированный текст) и udh, который содержит порядковый номер части внутри длинного составного сообщения.
Сама MotherSms это сообщение, которое изначально отправили на номер и которое надо в итоге получить. Собирать её из отдельных Sms мы будем на специальной фабрике MotherSmsFactory.
public class MotherSmsFactory { private static final String SMS_ROW_PATTERN = "%ninterface: %s%ndate: %s%nphone: %s%n%n"; private static final String TIME_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ssZ"; private static final Pattern COLON_OFFSET_PATTERN = Pattern.compile("([+-]\\d{2}):(\\d{2})$"); private static final long TIME_WINDOW_MS = 7_000; // 7 секунд
Мы передаём в метод фабрики список всех объектов Sms, прочитанных из файла присланного роутером, и пустой список для будущих объектов MotherSms.
public static void fillMotherSmsList(List<Sms> smsList, List<MotherSms> mothersList) { for (Sms sms : smsList) { // перебираем Sms long timeMillis = parseTimeToMillis(sms.getTimestamp());// получаем Unix время String smsSource = sms.getSource(); // имя интерфейса в роутере String smsPhone = sms.getPhone(); // телефон отправителя boolean addedToExistingGroup = false; // флаг принадлежности Sms к MotherSms for (MotherSms mother : mothersList) { // перебираем MotherSms если они уже есть long delta = Math.abs(timeMillis - mother.getGroupTimestamp()); if (delta <= TIME_WINDOW_MS && smsSource.equals(mother.getSource()) && smsPhone.equals(mother.getPhone())) { mother.addPart(sms); // Если в Sms совпало +- 7 секунд время, интерфейс и номер отправителя, //добавляем Sms в MotherSms addedToExistingGroup = true; break; } } if (!addedToExistingGroup) { // создаём MotherSms для первой свободной Sms MotherSms newMotherSms = new MotherSms(timeMillis, smsSource, smsPhone); newMotherSms.addPart(sms); mothersList.add(newMotherSms); } } concatByUdh(mothersList); Collections.sort(mothersList, (o1, o2) -> Long.compare(o1.getGroupTimestamp(), o2.getGroupTimestamp())); }
Теперь, когда все части распределены по своим материнским сообщениям, проводим финальную обработку внутри каждой MotherSms:
Удаляем полные дубликаты: иногда одно и то же сообщение отображается роутером несколько раз.
Фильтруем кривые SMS: удаляем неполные или повреждённые варианты, которые или прилетают от оператора или генерируются самим MikroTik (точно не знаю).
Сортируем по порядку: выстраиваем оставшиеся части
Smsпо их порядковому номеру (udh), полученному из заголовка.
В итоге собираем всё в финальную строку с шапкой для удобного отображения.
private static void concatByUdh(List<MotherSms> mothersList) { for (MotherSms motherSms: mothersList) { List<Sms> smsList = motherSms.getParts(); smsList = filterUniqueAndLongestMessages(smsList); // избавляемся от полных копий и перфиксов // сортируем по udh Collections.sort(smsList, (o1, o2) -> Integer.compare(o1.getUdh(), o2.getUdh())); StringBuilder builder = new StringBuilder(); for(Sms sms: smsList) { motherSms.setStringTimestamp(sms.getTimestamp()); builder.append(sms.getDecodeMessage()); // склеиваем наконец части длинной смс } String title = String.format(SMS_ROW_PATTERN, motherSms.getSource(), motherSms.getStringTimestamp(), motherSms.getPhone()); motherSms.setFinalText(title + builder + "\n"); } } private static List<Sms> filterUniqueAndLongestMessages(List<Sms> smsList) { // Шаг 1: Map<String, Sms> uniqueTextMap = new LinkedHashMap<>(); for (Sms sms : smsList) { String decodeMessage = sms.getDecodeMessage(); if (!uniqueTextMap.containsKey(decodeMessage)) { // избавляемся от дублей uniqueTextMap.put(decodeMessage, sms); } } // Получаем список уникальных Sms (в порядке их первого появления) List<Sms> uniqueSmsList = new ArrayList<>(uniqueTextMap.values()); int n = uniqueSmsList.size(); if (n <= 1) { return uniqueSmsList; } // Шаг 2: Поиск и удаление префиксов. // В массиве remove[i] == true, если сообщение с индексом i должно быть удалено. boolean[] remove = new boolean[n]; // Двойной цикл для попарного сравнения for (int i = 0; i < n - 1; i++) { if (remove[i]) continue; // Если Sms[i] уже помечено как удаляемое, пропускаем его String decodeMessageI = uniqueSmsList.get(i).getDecodeMessage(); for (int j = i + 1; j < n; j++) { if (!remove[j]) { String decodeMessageJ = uniqueSmsList.get(j).getDecodeMessage(); // Проверка на префикс: // Сценарий 1: decodeMessageJ длиннее и начинается с decodeMessageI (decodeMessageI - префикс/кусок) if (decodeMessageJ.length() > decodeMessageI.length() && decodeMessageJ.startsWith(decodeMessageI)) { // decodeMessageI — более короткий кусок decodeMessageJ, поэтому decodeMessageI удаляем remove[i] = true; break; // decodeMessageI удалено, нет смысла сравнивать его с другими } // Сценарий 2: decodeMessageI длиннее и начинается с decodeMessageJ (decodeMessageJ - префикс/кусок) else if (decodeMessageI.length() > decodeMessageJ.length() && decodeMessageI.startsWith(decodeMessageJ)) { // decodeMessageJ — более короткий кусок decodeMessageI, поэтому decodeMessageJ удаляем remove[j] = true; // Продолжаем цикл j, так как decodeMessageI (самое длинное на данный момент) //может быть префиксом еще более длинного. } } } } // Шаг 3: Сборка финального списка. List<Sms> fullList = new ArrayList<>(); for (int i = 0; i < n; i++) { if (!remove[i]) { fullList.add(uniqueSmsList.get(i)); } } return fullList; }
В результате получаем на экране смску, в том виде, в котором она была отправлена.

А кому это надо?
Зачем вообще надо было делать декодирование 7-bit самому если MikroTik и так это делает сам?
До недавнего времени я так и поступал, делал для 7-bit так.
decodeMessage = message;
Но оставалось ощущение, что что-то не доделано, тем более что я уже пытался это реализовать, но раньше не получалось. Теперь же, когда всё заработало, выяснилось если отправить на MikroTik сообщение в 7-bit с полным алфавитом GSM
@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\u001BÆæßÉ !"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà
...роутер отобразит его вот так:
@$$YeeuioCOoAaD_FGLOPPSTKAasE !"#$%&'()*+,-./0123456789:;<=>?!ABCDEFGHIJKLMNOPQRSTUVWXYZAONU.?abcdefghijklmnopqrstuvwxyzaonua
Он заменяет символы валют и многие другие знаки на похожие по начертанию. Будет очень обидно получить SMS с предложением зарплаты в $, и не согласиться работать за £.
Приложение написано для API 23+, поэтому некоторый код может показаться устаревшим или не самым оптимальным. Скачать его можно в Google Play.
Спасибо за внимание.
