Назад в 90-е или как отправить сообщение на пейджер через Java

в 16:24, , рубрики: dtmf, java, sip, voip, пейджер, Программирование

Прочитав заголовок, вы, наверное, немного удивились необычности задачи, которую я перед собой поставила. Однако, как ни странно, пейджеры до сих пор иногда могут пригодиться в жизни, даже несмотря на появившееся в последние 15 лет обилие других средств коммуникации. Один из частных случаев их применения — (около)медицинское учреждение, расположенное в железо-бетонном здании, глушащем WiFi и сигнал мобильного телефона. Обслуживающий персонал, тем не менее, должен каким-то образом получать сообщения о том, куда им надо срочно переместиться в случае чего. Для решения этой проблемы руководство учреждения в нашем случае поставило себе дорогую станцию и раздало всем сотрудникам пейджерА пейджеры, которые должны были среди прочих принимать наши сигналы. Соответственно, нашей (меня и моих коллег) задачей являлась их отправка.

Уже прошли те времена, когда для отправки текста на пейджер надо было сначала пообщаться с сонной девушкой с телефонного узла. Теперь достаточно дозвониться до станции и набрать номер абонента и сообщение в тоновом режиме. Арсенал при этом сильно ограничен: можно отправлять только цифры, символы * и #, иногда буквы ABCD. Но для передачи, скажем, номера комнаты или кода ошибки должно хватить. Это довольно сильно упрощает задачу и роднит её с другими — с дозвоном в общую переговорную комнату, например.

Несмотря на кажущуюся прозрачность решения и вторичность моего опыта, я решила подробно описать свои действия, потому что информации в интернете по теме представлено не очень много: на форумах на вопросы отвечают редко и неметко. Кому-то этот текст, возможно, сильно сэкономит время.

image

Шаг 1 — INVITE

Первый этап — дозвон на пейджинговую станцию — был реализован через протокол SIP и с помощью соответствующей Java-библиотеки jain-sip. Самое лучшее описание принципов работы протокола я нашла на Хабре в публикациях «Взаимодействие клиентов SIP. Часть 1» и «Взаимодействие клиентов SIP. Часть 2», а самый удобоваримый туториал по джейн — вот здесь (но коллекция примеров отсюда отказалась получше).

В качестве предпоготовки я создала класс:

public class SipNotificator implements SipListener

c необходимыми полями, которые вначале должны быть инициализированы так, как указано в туториале:

private SipProvider sipProvider;
private SipFactory sipFactory;
private SdpFactory sdpFactory;//пригодится позже
	
private AddressFactory addressFactory;
private HeaderFactory headerFactory;	
//private MessageFactory messageFactory; //не пригодилась

Как предписывают нам правила, сначала необходимо отправить INVITE-сообщение на телефон. Обратите внимание на то, что адресат в To- и Request-хедерах записывается по-разному. В первом случае заголовок просто собирается из глобального телефонного номера:

Address toNameAddress = addressFactory.createAddress( addressFactory.createTelURL(adresseenumber));
ToHeader toHeader = headerFactory.createToHeader(toNameAddress, null);

Во втором случае необходимо указать хост, с которого производится отправка сообщения, инициирующего общение:

URI requestURI = addressFactory.createAddress("sip:"+adresseenumber+"@"+host+";user=phone").getURI();

Другим интересным элементом является, собственно, тело SDP-сообщения, представляющее собой описание того, что понадобится для успешной коммуникации. В нашем случае оно выглядело примерно так:

String sdpData = "v=0rn"
                + "o=4444 123456 789054 IN IP4  "+InetAddress.getLocalHost().getHostAddress() +"rn" 
                + "s=phone callrn"
                + "p="+phoneusername+"rn" 
                + "c=IN IP4  "+InetAddress.getLocalHost().getHostAddress() +"rn"
                + "t=0 0rn" 
                + "m=audio "+localUdpPort+" RTP/AVP 0 8 18 101rn"
                + "a=rtpmap:0 PCMU/8000rn"  
                + "a=rtpmap:8 PCMA/8000rn"
                + "a=rtpmap:18 G729A/8000rn" 
                + "a=fmtp:18 annexb=norn"
                + "a=rtpmap:101 telephone-event/8000rn"
                + "a=fmtp:101 0-16rn"
                + "a=ptime:20rn"
                + "a=sendrecvrn";

Атрибуты «o» и «s» не являются особо важными, в «p» пишем свой телефон. Основная часть — «m» (media), в которой прописываются используемые кодеки (в нашем случае на отправителе они могут быть не установлены) и порт для принятия ответов по теме.

Шаг 2 — аутентификация

Если нам удалось отправить правильный инвайт, то в лучшем случае сервер-получатель пришлет нам желанное OK-сообщение со статусом 200, а в худшем — решит еще немного помучить идентификацией. Во втором случае ответый статус будет 401 или 407. Вот код, с помощью которого посылается ответ. Для его поддержки понадобится одна из последних версий jain-sip (например, 1.2.228). Его надо поместить в метод processResponse(), получающий в качестве аргумента ResponseEvent responseEvt.

if (status == 401|| status == 407){
		AuthenticationHelper authenticationHelper =  ((SipStackExt) sipProvider.getSipStack()).getAuthenticationHelper(
	                    		new AccountManagerImpl(this.phoneusername, this.password), headerFactory);						
					
		transaction = authenticationHelper.handleChallenge(responceEvt.getResponse(), responceEvt.getClientTransaction(), sipProvider, 15, true);			
		dialog = transaction.getDialog();							
		transaction.sendRequest();				
} 

Обратите внимание на четверый аргумент метода handleChallenge(), без него формат сообщения изменится, станет неподходящим и ваша аутентификация провалится.

Классы AccountManagerImpl и тоже неоходимый UserCredentialsImpl должны быть дописаны вами, я их писала по модели тех, что представлены здесь.

После отправки своих регистрационных данных мы можем смело ожидать искомый 200 OK, на который надо не забыть отправить ACK. Такой тип сообщения изготавливается крайне просто:

Request ackRequest = dialog.createAck( ((CSeqHeader) responseEvt.getResponse().getHeader(CSeqHeader.NAME)).getSeqNumber() ); 
//dialog - текущий диалог
Шаг 3 — SIP INFO

Дальше начинается самое интересное — отправка DTMF-сигналов (те самые нажатия в тоновом режиме). Глобально это можно сделать через два разных протокола: через SIP и через RTP . Естественно, поначалу было решено пойти по пути наименьшего сопротивления. Для каждого символа формировался вот такой запрос, который потом нужно было отправить на сервер:

Request info = dialog.createRequest(Request.INFO);
String sdpData = "Signal="+digit+"rn" + "Duration=200";
	        
byte[] contents = sdpData.getBytes();

ContentTypeHeader contentTypeHeader = headerFactory.createContentTypeHeader("application", "dtmf-relay");

info.setContent(contents, contentTypeHeader);

ClientTransaction transaction = sipProvider.getNewClientTransaction(info);
Dialog dialog =  transaction.getDialog();           
dialog.sendRequest(transaction);

Сама процедура отправки выглядит немного странно (кажется, построенной по модели «через Жмеринку в Париж»), но иначе у меня ничего не работало. Вообще библиотека мне показалась немного глючной: очень часто одно из нескольких решений, выглядевших по большому счету одинаково, не срабатывало.

Что я могу сказать? После реализации этого шага оказалось, что не все VoIP-сервера одинаково дружелюбны: некоторым достаточно было сигналов, передаваемых через SIP, а кому-то их не хватило, так как они не производят звукового сигнала и потому остаются незамеченными. Естественно, по закону подлости моей целью был сервер второго типа. Поэтому…

Шаг 4. формирование RTP-пакета

Вообще когда я осознала, что одним SIP-ом проблему не решить, я надеялась, что хотя бы смогу воспользоваться другой библиотекой, которая умеет ненапряжно отправлять DTMF-сигналы. Но не тут-то было. Обычно, если мы говорим «RTP через джаву», то подразумеваем JMF. Но, во-первых, она уже старенькая и не особо поддерживается. Во-вторых, она больше подходит для передачи более сложных медиа. В-третьих, туториалы, которые мне удалось найти, были не очень толковыми. Вот один из примеров из документации, в середине которого всплывает некая rtpSession, следов которой в первые сколько-то минут поиска мне найти вообще не удалось.

Другим вариантом была библиотека libjitsi, представляющая из себя целый коммуникатор. Из неё ничего позаимствовать тоже не удалось, хотя там есть милая метода sendDTMF или что-то в этом духе. Структура кода такова, что он берётся или целиком, или никак. В итоге было решено по-нормальному сделать человеческий пакет и отправить его через UDP-соккет.

Итак, вот значимый фрагмент класса RtpPacket: его основные поля и конструктор со значениями, подходящими для передачи DTMF. Что значат все эти вещи, написано много где, поэтому повторяться не буду. Отмечу только, что значение параметра ssrc в принципе роли не играет, но у всех отправляемых в одной сессии пакетов оно должно совпадать. Номер формата полезной нагрузки у DTMF-пакетов (payload type) — 101 (его мы прописали, когда инициировали SIP-коммуникацию).

    private int version;
    private boolean padding;
    private boolean extension;
    private int csrcCount;
    private boolean marker;
    private int payloadType;
    private int sequenceNumber;
    private long timestamp;
    private long ssrc;
    private long[] csrcList;
    private byte[] data;
    
    public RtpPacket(){
    	
    	this.setVersion(2);
        this.setPadding(false);
        this.setExtension(false);
        this.setCsrcCount(0);       
        long[] list = {};
        this.setCsrcList(list);
    }

Самый важный этап создания пакета — заполнение байтового массива данных. У DTMF, естественно свой формат: первый байт — это, собственно, значение передаваемого сигнала (от 0 до 16), первая половина второго байта — различные маркеты (обычно 0), вторая половина второго байта- громкость (стандартное значение — 10), остальные два — это длительность (стандартное значение — 160).

Для каждого сигнала создается около 10 пакетов (число может варьироваться):

— первый, начальный, имеет marker = 1, остальные — 0;
— последние три — конечные, marker = 0, зато первый бит второго байта блока данных = 1. Блок данных в неконечном пакете для передачи сигнала 1 будет выглядеть так:

0000 0001      0000 1010                0000 0000     1010 0000 
значение              громкость             длительность

А в конечном вот так:

0000 0001      1000 1010                0000 0000     1010 0000 
значение      end    громкость            длительность

Метка времени у всех DTMF-пакетов, относящихся к одному сигналу, может оставаться одинаковой (предположим, T). Зато время следующего пакета должно быть:

T+(количество DTMF-пакетов * длительность пакета)
Шаг 5. RTP-канал

Можно было создать только один соккет для отправления и принятия сообщений. Но в любом случае никакой отправки, естественно, не случится, если не знать хоста и порта назначения. Это уже совсем не те данные, через которые проходила SIP-коммуникация. Свои координаты телефонный сервер присылает нам в ответных SIP сообщениях во время нашего дозвона. Их можно получить, вставив в метод processResponce() вот такой код (тут можно увидеть, зачем мы ранее инициализаровали sdpFactory):

Response resp = responceEvent.getResponse();
int remoteHost;
int remotePort;

if (resp.getRawContent()!=null){
	String sdpContent = 
	new String(resp.getRawContent());  
	
       SessionDescription requestSDP = sdpFactory.createSessionDescription(sdpContent);
       remoteHost = requestSDP.getConnection().getAddress();//хост запрятан в Connection Information
				
      Vector<MediaDescription> media = requestSDP.getMediaDescriptions(false);
      for (MediaDescription m:media){
		if (m.getMedia()!=null )
			remotePort =m.getMedia().getMediaPort();//порт можно найти среди данных media
		}
	}

Дальше, как я наивно полагала, мне оставалось только понаделать из моих байтов DatagramPacket'ов, засунуть их в сокет и запулить в сервер. Но не тут-то было. В ответ сервер продолжал обрывать коммуникацию на полуслове, как будто ничего и не получал. А Wireshark в принципе не принимал мои сообщения за RTP, отображая из как простые UDP.

Шаг 6. RTP-коммуникация

На то, чтобы понять, в каком направлении двигаться дальше, ушло много времени. Я вложила много усилий в то, чтобы перечитать все имеющиеся в наличии спецификации и сто раз проверить свои пакеты на правильность. На седьмой же день Зоркий Глаз в моем лице заметил, что стандартная RTP-коммуникация не начинается сразу же с отправки DTMF-данных, а что ей предшествует непродолжительный обмен пакетами с сервером, которые выглядят несколько иначе.
Формат полезной нагрузки, объявленный в заголовке, равен 0, данных нет, зато есть собственно сама полезная нагрузка (payload), которая занимает 160 байт. Этот набор байтов различается во всех приходящих и уходящих сообщениях и выглядит составленным довольно случайно. Так или иначе, я не смогла найти информации о том, как именно он должен формироваться, поэтому каждый раз забивала его рандомами.

После того, как я стала отправлять эти вспомогательные пакеты перед каждым DTMF-сигналом, Wireshark наконец-то признал RTP-формат. Всё выглядело лучше, но коммуникация по-прежнему прерывалась, хотя сервер теперь от радости тоже стал меня забрасывать «пейлодными» пакетами.

Я уже и не знала, что еще бы могла сделать, но тут вспомнила, что RTP есть брат-неразлучник — RTCP. Проблема, по всей видимости, действительно была в нем: сервер пытался мне что-то отправить, но ему от меня постоянно приходили сообщения о том, что соответствующий порт закрыт. Поскольку я не хотела заморачиваться отправкой еще и RTCP-пакетов, я начала просто с открытия чакры порта:

DatagramSocket socket = new DatagramSocket(localUdpPort, InetAddress.getLocalHost());			 

DatagramSocket controlSocket = new DatagramSocket(localUdpPort+1, InetAddress.getLocalHost());//порт должен быть на 1 больше, чем у RTP

Это оказало решающее воздействие: абонент получил моё сообщение «305*1*66» на пейджер!

Заключение

В последних строках моей телеги хотелось бы подчеркнуть, что это мой первый пост на Хабре, так что не судите меня строго. Я совершенно не считаю себя гуру телематики или чего-либо ещё. Просто при написании исходного кода очень много времени ушло на поиск информации. Что-то я находила в спецификациях, которые от начала до конца в один присест осилить было сложновато, что-то было описано нормальным языком, но как-то неярко мелким шрифтом на полях, что-то я делала наугад. Так что в какой-то момент просто решила, что если у меня всё получится, я опишу все свои действия в одном месте и оставлю это индексироваться куда-нибудь в интернет.

Так что очень надеюсь, что хоть кому-то моя статья пригодится или хотя бы покажется интересной.

Автор: fainalex

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js