Радиотелефончик на смартфоне
Немало воды утекло со времени публикации цикла про стриминг видео на Андроид устройствах, но вот ручки добрались и до аудио потоков. Не то, чтобы это была какая-то более заковыристая тема про сравнению с видео, даже наоборот, сложно придумать что-то проще, ибо Audio API не менялось, дай бог памяти, с 2012 года, если не раньше. И не стоило бы, ради этого пилить короткий пост, если бы не зудящая мысля —, а на какое расстояние и каким образом можно передать сей аудио поток, если мы будем использовать для этого только два смартфона без всякой мобильной связи и внешних точек доступа.
Если вам интересно узнать, что из этого получилось, то прошу проследовать под кат…
Понятно, что в начале любой здравомыслящий человек посмотрит, собственно, на радиомодуль своего сотового телефона. Действительно, размер соты для уверенного приёма, может составлять несколько километров в поперечнике (у AMPS вообще до 20 км, да и даже LTE ненамного меньше). И если с одной стороны базовая станция может подать весьма мощный сигнал, который легко детектируется, то с другой стороны и сам смартфон обладая, пусть и десятками и сотнями милливатт выходной мощности, но также доносит свой голосок до станции. А если роль базовой станции сыграет другой телефон? Ясно, что расстояние будет в этом случае всё равно меньше, поскольку усилители входных сигналов на станции явно будут помощнее, чем на телефоне, но не в принципиальной же степени (тут мы сразу оговариваемся, что эксперимент проводится в местах, где нет мобильного покрытия, чтобы никому ни в коем разе не мешать).
Другое дело, что API для работы с радиомодулем в современных смартфонах, если и существует (допустим для разработки), то оно наверняка, закопано очень глубоко и совершенно недоступно для пет девелоперов. И если уж делать радиотелефон на базе сотового, то логичнее брать древние модели, по которым уже всплыла внутренняя документация, и на которых ещё можно сыскать какие-нибудь тестовые разъемы и даже к ним подпаяться для оболванивания их электронных мозгов. И таки да, буквально месяц назад на Хабре же вышла прекрасная статья по этой теме, которую я с удовольствием прочитал.
Но как и вы сами можете убедиться, процесс подъёма связи здесь весьма непростой, требует хождения по помойкам, усидчивости и прямых рук даже с исчерпывающим руководством, а результат — коннект в пределах не дальше комнаты, необходимость тащить синхросигнал с чьей-то БС, плюс некоторая вероятность прибытия людей в чёрном. Понятное дело, что какая-то практичность проекта и не подразумевалась, главное было в торжестве человеческого гения, но мне мнилось замахнуться на несколько большие расстояния, чтобы это было больше похоже на настоящую радиосвязь.
Поэтому подивившись силе разума гиков, я обратился к тому, что у меня было уже в руках — двум смартфонам фирмы Сяоми, одному поновее, на Android 12 и второму постарше, на десятке.
Ну, блютус отпал сразу, поскольку на расстоянии на котором он стабильно работает, вы без труда можете переговариваться и без использования телефонов. Работа непосредственно с GSM модулем, тоже сами понимаете, была из разряда ненаучной фантастики. Так что оставался лишь старый добрый Wi-Fi. Идея была такова: поднимаем, значит, на одном смарте локальную сеть (или local Hotspot по английски), а вторым смартфоном к этой сети цепляемся и начинаем гнать звук соответственно от их микрофонов к противоположным динамикам двумя аудио потоками — короче устанавливаем голосовую связь! А потом шаг за шагом проверяем, насколько и на каком расстоянии эта связь устойчиво поддерживается.
Итак, задача понятна, приступаем к ее решению. Начнём естественно с аудио. Как уже упоминалось, API это весьма древнее, и если вам хочется асинхронности и колбэков для записи и приёма данных, то это не сюда. Добро пожаловать в отдельный поток и в цикл while do.
Вообще, самый простой способ записи с микрофона и последующего его воспроизведения через динамик — это использование класса MediaRecorder. Там даже с потоками не надо связываться. Просто учреждаем экземпляр MediaRecorder, определяем куда писать и из какого источника (в нашем случае это микрофон.
mAudioRecorder = new MediaRecorder();
mAudioRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
mAudioRecorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
mAudioRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
mCurrentFile = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
mAudioRecorder.setOutputFile(mCurrentFile.getPath());
mAudioRecorder.start();
А проигрываем соответственно через MediaPlayer ():
mAudioPlayer=new MediaPlayer();
mAudioPlayer.setDataSource(mCurrentFile.getPath());
mAudioPlayer.prepare();
mAudioPlayer.start();
Тут настолько всё просто, что даже не требует пояснений. Не забудьте только оформить разрешение на использование микрофона и файлового хранилища.
Но как легко видеть, для передачи живого аудио потока эти классы подходят мало. Здесь нам пригодится другой класс — AudioRecord, который может работать непосредственно с байтовым потоком (raw data). А чтобы это аудио поток так же потом проигрывать в реальном режиме времени, сгодится класс AudioTrack.
Вообще AudioRecord может так же невозбранно писать аудиопоток в файл как и MediaRecorder, но нам такая его способность не понадобится.
Итак, пишем:
private AudioRecord audioRecord;
private int SampleRate = 8000;
private int minBufferSize;
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
......
audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SampleRate,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
minBufferSize*2);
Или можно по модному через билдер, получится то же самое:
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
audioRecord = new AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build())
.setBufferSizeInBytes(2 * minBufferSize)
.build();
Как мы видим, у нас там есть переменные ENCODING_PCM_16BIT, SampleRate и minBufferSize. Рассмотрим их чуть подробнее.
Для перевода аналогового потока с микрофона в цифровой вид здесь используется формат PCM — импульсно кодовая модуляция. То есть с определенной частотой дискретизации (SampleRate) уровень аудио сигнала измеряется и переводится в формат 8 или 16 двоичных разрядов (или даже в формат с плавающей запятой для особых гурманов). Какая же должна быть частота и разрядность в нашем случае? В принципе можно взять формат, хоть как для аудио CD. Там, как известно, частота дискретизации равна 44,1 кГц, а разрядность 16 бит. Но тогда наш аудио поток изрядно распухнет в размерах, даже если мы будем записывать звук в одноканальном (моно) режиме ибо:
44100×16 = 705 600 бит в секунду (и это не считая всяких служебных).
И именно с такой скоростью нам придется передавать их по сети. Кажется многовато.
Но опять же, мы передаем не симфоническую музыку, а просто голос. Поэтому, памятуя о ширине обычной телефонной линии в 3 кГц и теореме Котельникова, согласно которой, для восстановления сигнала данной ширины спектра, надо всего лишь удвоить частоту дискретизации, то можно ограничиться SampleRate равным 8000. Это вообще полоса в четыре килогерца — даже шире, чем нам нужно.
Можно использовать разрядность 8 бит и добиться скорости передачи потока всего лишь — 8000×8 = 64 000 бит в секунду, что как бы является стандартной скоростью цифровой телефонии. Поток несжатый, но нам городить лишние сущности, пока ни к чему. Правда, при уменьшении разрядности до 8 бит, появляются какие-то раздражающие фоновые шумы (хотя голос вполне разборчив), поэтому можно сильно не экономить и оставить разрядность в 16 бит, тем более, что скорость передачи данных даже 16 кбайт в секунду наш смартфон не обессилит.
Что же касается переменной minBufferSize, то это минимальный размер внутреннего буфера, при котором объект AudioRecord сможет работать.
И вычисляется он по формуле:
minBufferSize = AudioRecord.getMinBufferSize(
SampleRate, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT);
Формула не слишком очевидная, поэтому ради интереса, я вывел значение minBufferSize в лог. Он оказался равен 640. Наверное, байт. Во всяком случае, на фоне формирования двухбайтных отсчетов восемь тысяч раз в секунду, цифра невелика и поэтому не зря, видимо, все советуют её (то есть размер внутреннего буфера) увеличивать раз в десять. Ну, как минимум, в два.
После учреждения же всех начальных параметров, можно переходить собственно к запуску инстанса AudioRecord. Запускается он, естественно, в отдельном потоке в цикле while (true или какой-нибудь триггер).
public void run() {
try {
audioRecord.startRecording();
byte[] outData = new byte[minBufferSize];
try {
while (mIsCapturing) {
// read audio data from internal mic
audioRecord.read(outData,0, outData.length);
}
} finally {
audioRecord.stop();
}
} finally {
audioRecord.release();
}
То есть, пока переменная mIsCapturing установлена, audioRecord по готовности аудио данных, кладёт содержимое заполненного внутреннего буфера в байтовый массив outData. И нам остается лишь взять этот массив (пока он не затёрся новыми значениями) и отправить его, куда душа пожелает. Можно в байтовый поток, а потом в файл, но можно и в датаграммный пакет, который потом поедет по сети Wi Fi к нужному адресату.
Обратный процесс (из цифры в ухо), тоже больших проблем не доставляет. Главное там, задать те же параметры дискретизации, что и при записи. Здесь с байтами работает класс AudioTrack.
Правда ему нужны дополнительные параметры в лице AudioAttributes и AudioFormat поэтому писанины получается несколько больше.
AudioAttributes audioAttributes = new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) // defines the type of content being played
.setUsage(AudioAttributes.USAGE_MEDIA) // defines the purpose of why audio is being played in the app
.build();
AudioFormat audioFormat = new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) // we plan on reading byte arrays of data, so use the corresponding encoding
.setSampleRate(SampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build();
AudioTrack audioIn = new AudioTrack(audioAttributes, audioFormat,
2*minBufferSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE);
Дальше, так же в отдельном потоке запускаем приём байт (пока мы молчаливо предполагаем, для примера, что они до нас как-то добираются частями, допустим, по 1024 байт).
public void run() {
byte [] inSoundData = new byte[1024];
while (true) {
try
{
audioIn.write(inSoundData, 0, inSoundData.length);
}
catch (Exception e)
{
// handle exception
}
audioIn.play();
}
}
Тут, главное запускать audioIn.play () после прихода каждой порции данных. Потому как в интернете я сыскал пример кода, где автор шутник расположил его после цикла while. И нескоро я разобрался, почему вместо нормального воспроизведения файла (в начале я писал данные в файл), я слышал что-то вроде короткого «блурп…» или вообще была полная тишина.
Итак, с записью и воспроизведением мы разобрались. Осталось лишь отправить и принять байты аудио данных по сети. Пока упростим задачу и примем, что сама локальная беспроводная сеть у нас есть, наш смартфон к ней подсоединён и имеет свой постоянный IP адрес. Можно конечно взять для демонстрации адрес 127.0.0.1, но мы не будем проявлять малодушие и действительно заставим звуки путешествовать по сети.
Работа с UDP протоколом (TCP, сами понимаете, здесь излишен) была рассмотрена в цикле статей про видео стриминг на Андроид устройствах, поэтому сильно подробно останавливаться на этой теме не будем.
В коде это выглядит так. Сначала учреждаем два UDP сокета, соответственно для отправки и получения данных. Номера портов пока у них одинаковые, поскольку мы работаем с одним устройство и шлём данные, хоть и по сети, но сами себе. Далее, соответственно запускаются два отдельных потока по приему и передаче аудио. И в этих же потоках начинают работать audioRecord.startRecording () и audioIn.play () , записывая и воспроизводя аудио поток.
Итак:
private byte inSoundData[];
DatagramSocket udpSocketOut;
DatagramSocket udpSocketIn;
String ip_address = "IP адрес устройства в локальной сети";
InetAddress address;
int portOut = 40000;// выход для пакетов со звуком/40000
int portIn = 40000;// вход для пакетов со звуком// 40000
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mIsCapturing = true;
try {
udpSocketOut = new DatagramSocket();
udpSocketIn = new DatagramSocket(portIn);
Log.i(LOG_TAG, " создали udp каналы");
} catch (Exception e) {
Log.i(LOG_TAG, " Не создали udp каналы " + e);
}
new AudioSend();// для отправки звука по UDP
new Udp_recipient();// запускаем прием UDP пакетов
try {
address = InetAddress.getByName(ip_address);
Log.i(LOG_TAG, " есть адрес");
} catch (Exception e) {
Log.i(LOG_TAG, " нет адреса " + e);
}
}
private class Udp_recipient extends Thread {
Udp_recipient() {
Log.i(LOG_TAG, "запустили прием данных по udp");
start();
}
public void run() {
while (true) {
try {
byte buffer[] = new byte[32768];
DatagramPacket p = new DatagramPacket(buffer, buffer.length);
udpSocketIn.receive(p);
byte bBuffer[] = p.getData();
inSoundData = new byte[p.getLength()];
synchronized (inSoundData) {
for (int i = 0; i < inSoundData.length; i++) {
inSoundData[i] = bBuffer[i];
}
}
}
catch (Exception e) {
Log.i(LOG_TAG, e + "hggh ");
}
try
{
audioIn.write(inSoundData, 0, inSoundData.length);
}
catch (Exception e)
{
// handle exception
}
audioIn.play();
}
}
}
public class AudioSend extends Thread {
AudioSend() {
Log.i(LOG_TAG, "запустили прием данных по udp");
start();
}
@Override
public void run() {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO)
try {
Log.i(LOG_TAG, "запустили отправку ");
audioRecord.startRecording();
byte[] outData = new byte[minBufferSize];
try {
while (mIsCapturing) {
// read audio data from internal mic
audioRecord.read(outData,0, outData.length);// получили в байтовый массив порцию звука
// set audio data to UDP сокет
try{
// Log.i(LOG_TAG, " outDate.length : " + outDate.length);
DatagramPacket packet = new DatagramPacket(outData, outData.length, address, portOut);
udpSocketOut.send(packet);
}
catch (Exception e)
{
Log.i(LOG_TAG, "не отправили UDP пакет");
}
}
} finally {
audioRecord.stop();
}
} finally {
audioRecord.release();
Log.i(LOG_TAG, "остановили tred");
}
}
}
Не забудьте только добавить соответствующие разрешения на работу с сетью в манифесте и в самом приложении. И после можете наслаждаться всеми звуковыми эффектами, эхом, реверберацией и положительной обратной связью переходящей в противный свист. Но ежели, подключить наушники, неважно блютус или проводные, то можно более комфортно слушать свою речь. Ещё удивила чувствительность сяомиевского микрофона: стоишь в беспроводных наушниках в 10 метрах от телефона, говоришь еле слышным шепотом, в противоположную сторону, а из наушников всё отчетливо слышно.
В принципе, для телефонизации поселка и походного стана (где нет мобильного покрытия) этого достаточно. Устанавливаем в сельсовете или шатре предводителя мощный роутер с внешней антенной, а вокруг в избах или палатках дружины размещаем маршрутизаторы попроще (как репитеры). Подключаетесь к сетке главного роутера и можете бесплатно разговаривать по смартфонам хоть сутками. Там надо лишь допилить регистрацию абонентов и опцию выбора кому звонить.
Но поскольку я хотел от лишнего звена, роутера избавиться, то пришлось двигаться дальше, дабы сконфигурировать и запустить беспроводную локалку непосредственно у себя на смартфоне.
Для этой цели современный Android, вроде как, предлагает прекрасное современное (на колбэках!) технологическое решение local-only hotspot, чтобы приложения могли соединяться с друг другом по Wi-Fi для обмена данными. И даже заботливо предупреждает, что мол, сеть будет только внутренняя, а в Сеть на ней не выйдешь. Ну, нам в принципе, этого не требуется. Инициализация же этого дела выглядит примерно так:
wifiManager = (WifiManager) this.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
wifiManager.startLocalOnlyHotspot(new WifiManager.LocalOnlyHotspotCallback() {
@Override
public void onStarted(WifiManager.LocalOnlyHotspotReservation reservation) {
super.onStarted(reservation);
Log.d(LOG_TAG, "Wifi Hotspot is on now");
mReservation = reservation;
currentConfig = mReservation.getWifiConfiguration();
Log.d(LOG_TAG, "THE PASSWORD IS: "
+ currentConfig.preSharedKey
+ " \n SSID is : "
+ currentConfig.SSID);
После чего вы получаете сетку с уже предустановленными названием (Android плюс чего-то там) и паролем. Нет, ну ладно сеть можно найти по слову Андроид в названии, а вот как бедному приложению пытающемуся к ней присоединиться, получить пароль??? Может можно сконфигурировать свою сеть с нужными названием и паролем? Смотрим страницу SoftApConfiguration (на официальном сайте!), и находим там ссылку SoftApConfiguration.Builder . И видим:»404 | страница не найдена».
Самое интересное, что в интернете примеры работы с этим билдером есть. Есть он и в доках Андроида. А в Android Studio — нет. Я и студию обновил, после чего текущие проекты жестко забаговались и слетели некоторые настройки (как вам такое: чтобы исправить внезапно возникшие ошибки Gradle, надо кое-где в файле сборки вставить слово version. Stack overflow не даст соврать); и поставил версию SDK API 33 Tiramisu, но все было тщетно, SoftApConfiguration.Builder не воспринимался системой никак.
После этого я стал примерно понимать ощущения безвестного участника Stack Overflow, который задал вопрос, а почему мол, эта новая сеть hidden, то есть скрыта. На что бездушный бот ответил ему, что он такие общие вопросы не понимает, пишите дескать пример кода. А я его понял, LocalOnlyHotspot вроде есть, и всё для его конфигурации имеется, а на практике — его нет.
Чуть позже на этом же форуме я нашел что:
- Только системные приложения могут конфигурировать LocalHotspot
- setSoftApConfiguration () это защищенное/ скрытое API, которым вы можете пользоваться только, если у вас кастомный SDK или SDK от третьих лиц, но всё равно в приложении оно работать не будет, если приложение не системное.
То есть копеечный роутер на линуксе сеть поднимает, а пет разработчик на Aндроиде видит вместо этого от Андроид разработчиков шиш. Что там такого в этой беспроводной локалке страшного и ужасного, что энтузиастам палки в колеса суют?
Но может быть, я неправ, и они наоборот вместо копания в IP адресах, просто предоставляют гикам продвинутые инструменты более высокого уровня? Вот например есть же Wi-Fi Direct и еще Wi-Fi Aware. Может надо пользоваться именно ими?
Действительно, на официальной странице прямо так и указано: Wi-Fi Direct (P2P) позволяет устройствам с соответсвующим аппаратным обеспечением устанавливать друг с другом связь по Wi Fi без промежуточной точки доступа. А в обсуждениях всё того же Stack Overflow, народ также писал, что Wi-Fi Direct работает вместе с беспроводной сетью (то есть без потери основного Wi Fi соединения) и даже не требует ее отключения. Это, мол, огромное достижение.
Обнадёженный этой информацией, я начал знакомиться Wi-Fi Direct поближе. На первый взгляд всё было прекрасно. Можно сказать, вместо ассемблера работаешь с современным языком высокого уровня: подписчики, чаты, сообщения, группы, публикации и т.д.
В соответствии с руководством я успешно создал экземпляр WifiP2pManager и даже зарегистрировался в Wi-Fi P2P фреймворке вызвав метод initialize (). Но тут, я нечаянно выключил тот самый основной Wi Fi, короче отсоединился от своей домашней сети.
И всё! Тут же пришел интент, что Wi-Fi P2P недоступен!
Как оказалось, мне показалась, что всё это всего лишь обёртка над тривиальным Wi Fi. То есть, в принципе, правильно написали, что Wi-Fi Direct не мешает работе основному Wi Fi соединению. Просто забыли написать, что без основного Wi Fi соединения, Wi-Fi Direct в принципе работать не может.
Но это была ложная тревога. Просто при выходе из домашней локалки я отключил и Wi Fi модуль смартфона. А для Wi-Fi Direct он должен работать, не важно, подключен он при этом к какой-либо сети или нет. Правда, это я уяснил через пару дней, в течении которых я пытался сконфигурировать и поднять беспроводную локалку — дедовскими способами через методы Reflection API.
Это выглядит примерно так, сначала конфигурируем сеть:
WifiConfiguration wcfg = new WifiConfiguration();
wcfg.SSID = " SSID NAME";
wcfg.networkId = Int;
wcfg.preSharedKey = "password";
wcfg.hiddenSSID = false;
wcfg.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN);
wcfg.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.TKIP);
wcfg.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK);
wcfg.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.TKIP);
wcfg.allowedProtocols.set(WifiConfiguration.Protocol.WPA);
А потом делаем финт ушами:
Method method = mWifiManager.getClass().getMethod("setWifiApConfiguration",
wcfg.getClass());
method.invoke(mWifiManager, wcfg);
И всё, сеть сконфигурирована. И даже можно получить ее параметры стандартными get-методами
Но современные (Android 12) и относительно современные (Android 10) смартфоны эти reflection методы не видят в упор, и код взлетел лишь на стареньком Самсунге с шестым Андроидом.
Но радовался я недолго, так как сеть мало сконфигурировать, её надо ещё и включить!
На это есть метод:
Method method1 = mWifiManager.getClass().getMethod("setWifiApEnabled",
WifiConfiguration.class, boolean.class);
method1.invoke(mWifiManager, wcfg, true);
Но он выдал исключение, которое привело меня к разрешению CONNECTIVITY_INTERNAL, которое, как оказалось, дается только системным приложениям.
Рутовать для этого даже старый телефон мне было лень и дальнейший поиск, так сказать, на этом остановился.
Я уже собирался вернуться к Wi-Fi Direct, но поскольку сокеты там пишутся немного по другому и вообще другая идеология, то вылетал весь код написаный для обычной Wi Fi сети и надо было писать новый. Поэтому радиотелефон на Wi Fi Direct я отложил для нового поста на Хабре, а затем здраво рассудил, что коль для инициализации своей сетки, надо сделать приложение системным, то почему бы не воспользоваться уже действующим системным приложением, которое работает, наверное, на всех современных телефонах. Это — Точка Доступа. Там, правда, надо на нее нажать пальцем, потом вбить свое название и пароль, что конечно, не комильфо. Но для проверки принципа и такой подход сойдёт.
Далее, со второго смарта надо подсоединиться к этой точке доступа (тоже ручками), и уже в параметрах мы увидим там и IP адрес шлюза (это первый смартофон) и свой IP адрес в этой сети. Теперь, если указать эти адреса в текстах программ, то радиотелефон у нас уже, можно сказать, заработал!
Теперь наступила пора полевых исследований. В следующую субботу, поколотив друг друга палками на тренировке по исторической реконструкции, я взял напарника с собой на тихую питерскую улицу и поведал ему свою тайну. Давно не видывал я такого детского восторга в глазах взрослого небритого мужика. Да? Рация на телефоне? Так это ж можно…
Восторги правда поутихли, когда наступили собственно практические испытания. Итог — зона уверенного приема в городе сто метров. На ста десяти/двадцати просто теряется сеть, а с ней, сами понимаете, и возможность живого общения. Это если на частоте 2,4 ГГц. На 5 ГГц ещё меньше, даже уже точно не считали.
На этом, в принципе, исследования завершились. Ради интереса можно ещё попробовать запустить связь на Wi Fi Direct, но скорее всего на дальность это не повлияет, так как всё зависит от мощности и чувствительности Wi Fi модуля, а у нас к нему доступа нет.