Привет, я Павел Семенищев, сетевой инженер в Yandex Infrastructure. В команде Network Operations Center (NOC) мы отвечаем не только за магистральные и дата‑центровые сети, но и за офисные, а также сети складов и дарксторов Яндекс Лавки. А это ОЧЕНЬ много удалённых точек присутствия, и при проблемах с Wi‑Fi на каждую сетевика не отправишь.
Для быстрого сканирования параметров сети на местах я создал WiProber под Android и WiFi Prober под iOS — получился сетевой «комбайн» для инженера, который сначала был нашим внутренним инструментом, а теперь есть и в общем доступе. Под катом расскажу, что умеют эти приложения, и какие ограничения операционных систем удалось обойти при их создании.
Для чего нужен свой сканер и что он может
В нашу зону ответственности входят около 700 дарксторов, разбросанных по всей стране, — и нам нужно было разобраться, как работает Wi‑Fi‑сеть в каждой отдельной точке. В меньших масштабах решить такие проблемы помогает радиообследование: с использованием профессионального оборудования вроде Ekahau Sidekick инженер может точечно найти неисправности Wi‑Fi и оптимизировать покрытие сети.
Но в нашем случае такой вариант был нереальным: логистика сложная, дорогого оборудования и ресурсов инженеров в таком объёме просто нет. При этом диагностику провести всё‑таки надо. Тогда и появилась идея: сделать приложение, с которым любой человек на месте сможет пройтись по складу, а мы получим валидный инженерный отчёт.
Первый год я занимался разработкой под Android, поскольку на местах, как правило, всегда есть сотрудники или смежники с самыми обычными Android‑смартфонами. А уже затем появилось отдельное приложение для отображения параметров подключения в App Store. Функциональность на разных платформах немного отличается, поэтому сначала покажу, что умеет каждое из приложений, а потом расскажу подробнее про их создание.
WiProber v2.0: Android-приложение для радиообследования
В радиообследовании есть два метода для сканирования эфира:
Stop-and-Go — останавливаемся, измеряем, делаем пару шагов, снова останавливаемся и т.д.
Continuous — непрерывно двигаемся и делаем измерения параллельно.
Нам хотелось реализовать именно второй режим.
Не менее важным условием был экспорт в формат Ekahau (.esx), поскольку это стандарт для отчётов после радиообследования. Так полученный файл можно открыть и проанализировать в профессиональном софте Ekahau AI Pro, которым пользуются многие сетевые инженеры.
Исходя из вводных, у приложения две главных киллер-фичи:
Режим Continuous: не надо тыкать в карту каждые 5 метров. Нажал «Старт», прошёл по маршруту, нажал «Стоп», а приложение тем временем само пишет трек и данные.
Полная совместимость с Ekahau: на выходе получаем файл .esx, который открывается в Ekahau AI Pro.
Для Wi‑Fi‑инженера эти две фичи закрывают вполне конкретные боли. Continuous‑режим — привычный по Ekahau Pro «walking survey»: идёшь по объекту в нормальном темпе, а не дёргаешься в Stop‑and‑Go каждые пару метров, и при этом получаешь плотный трек измерений вдоль реального маршрута. Экспорт в.esx означает, что переучиваться не надо — открываешь результат в Ekahau AI Pro и получаешь все привычные артефакты: тепловые карты покрытия RSSI и SNR, наложение измерений на план. По сути, телефон становится дешёвым «полевым датчиком», а аналитика и оформление отчёта остаются в том же приложении, к которому инженер уже привык.
Эту разработку я выложил в опенсорс. После перевода на английский у приложения появилась дополнительная аудитория из разных стран, которая оставляет полезную обратную связь.

WiFi Prober: приложение под iOS для отображения параметров подключения
На iPhone тоже хотелось иметь инструмент уровня «достал из кармана — продиагностировал проблему». Поэтому в App Store я выложил отдельное приложение. Вот что оно умеет:
Deep Signal Analysis. Графики RSSI и Noise Floor в реальном времени. Можно увидеть историю сигнала, а не просто текущее значение, а также удобно мотать историю измерений и видеть события роуминга на графике.
Roaming Log. Приложение ловит моменты переключения клиента между точками доступа (BSSID) и каналами. Незаменимо при отладке «липких» клиентов и бесшовного роуминга. Данные об уровне сигнала, BSSID, канале и timestamp позволяют отслеживать залипание клиента, корректность выбора кандидата и работу 802.11k/v.
Latency Monitor. Честный TCP‑пинг. Приложение само определяет шлюз (Gateway) и пингует его параллельно с внешним хостом (ya.ru). Сразу видно, где проблема — во внутренней сети или у провайдера.
Phy Specs. Вычисляет ширину канала (20/40/80 MHz) и MCS Index на лету.
OUI Database. Умеет определять производителя оборудования, благодаря встроенной офлайн‑базе OUI на 45 000 записей. Видно не только MAC, но и железо (Cisco, Mikrotik, Ubiquiti, Apple).

Killer Feature — AR Mode: включаете камеру, и приложение накладывает параметры сети поверх реального мира (HUD). Удобно делать радиообследование и записывать видеоотчёты прохода по объекту. Это walking survey без планшета и без Ekahau Sidekick: проход по объекту с видеозаписью, где параметры наложены на реальный мир — готовый визуальный отчёт заказчику.

.esx изнутри: два месяца на реверс бинарного формата
Прежде чем рассказывать про засады ОС, стоит сказать пару слов про формат, под который, собственно, всё это пишется.
Файл.esx — это обычный zip‑архив, внутри которого лежит десятка полтора JSON‑файлов: project.json, floorPlans.json, accessPoints.json, measuredRadios.json, accessPointMeasurements.json, surveyLookups.json, survey-{uuid}.json, notes.json, pictureNotes.json, images.json, projectHistorys.json, wifiAdapterInformations.json — и набор справочников типа wallTypes.json, deviceProfiles.json, floorTypes.json и т.д. Всё это связано между собой через UUID: у точки доступа есть id, на него ссылается запись из measuredRadios.json через accessPointId, заметки держат список imageIds из images.json, и так далее. JSON‑часть реверсится быстро: распаковал zip — и видишь модель проекта почти один в один.
А вот сырые результаты измерений — RSSI‑семплы по каждой AP в каждой временной точке маршрута — лежат не в JSON, а в отдельных бинарных файлах внутри того же архива: по файлу на survey, с именами вида track-{uuid}.bin. И вот тут начинается настоящий хардкор: формат закрытый, документации нет, единственный источник истины — поведение Ekahau AI Pro, которое нужно объяснить наблюдением. На реверс ушло около двух месяцев в режиме «диффов»: создаю в AI Pro проект с одним survey и одним семплом, экспортирую в.esx, распаковываю, смотрю байты. Потом добавляю ещё один семпл — смотрю, что поменялось. Смотрю на результат сканирования сети в Ekahau в той же точке пространства с тем же адаптером с байтами бинарника, делаю предположения, что есть что.
В итоге картина оказалась такой: порядок байтов — big‑endian (а не little‑endian, как обычно ждёшь от современных форматов), файл начинается с двух байт глобального заголовка 02 01, а дальше идут блоки сканирований. Внутри каждого блока — однобайтовый маркер начала, целочисленный индекс скана, затем переменное число записей по AP, и однобайтовый «футер» в конце блока. Каждая запись по AP — это последовательность «тег‑байт + значение»: 0x01 — начало AP‑блока, 0x02 за которым Int32 относительного таймстемпа в миллисекундах, 0x04 за которым Int16 индекса AP в глобальном списке accessPointMeasurements, 0x05 за которым Int8 RSSI, 0x11 за которым Int32 частоты в МГц, и 0x09 — конец AP‑блока. Никаких magic‑строк, никакого CRC, длины блока нигде явно не записаны — длина AP‑записи фиксированная (17 байт), длина блока скана выводится только по маркеру конца. Именно это и съело основное время: без явных длин любая ошибка в одном поле уводит парсер по байтам, и весь следующий блок «съезжает».
GLOBAL_HEADER ├─ 0x02 └─ 0x01 SCAN ├─ 0x00 ├─ scanId (4 bytes) ├─ AP │ ├─ 0x01 │ ├─ 0x02 + timestamp(4) │ ├─ 0x04 + apIndex(2) │ ├─ 0x05 + rssi(1) │ ├─ 0x11 + frequency(4) │ └─ 0x09 ├─ AP │ ... └─ 0x10 SCAN ...
Вот как это выглядит в реальном коде (фрагмент BinaryDataSerializer.kt из репозитория, слегка обрезанный):
private val BYTE_ORDER = ByteOrder.BIG_ENDIAN fun serialize(measurements: List<MeasurementEntry>): ByteArray { val groupedScans = measurements .groupBy { it.relTimestamp } .toSortedMap() // ... расчёт totalSize: 2 байта header + (5 + N*17 + 1) на каждый скан val buffer = ByteBuffer.allocate(totalSize).order(BYTE_ORDER) buffer.put(0x02.toByte()); buffer.put(0x01.toByte()) // global header var scanIndex = 0 groupedScans.forEach { (_, apList) -> buffer.put(0x00.toByte()); buffer.putInt(scanIndex) // scan id apList.forEach { item -> buffer.put(0x01.toByte()) // AP start buffer.put(0x02.toByte()); buffer.putInt(item.relTimestamp) // ts, ms buffer.put(0x04.toByte()); buffer.putShort(item.apIndex.toShort()) buffer.put(0x05.toByte()); buffer.put(item.network.level.toByte()) // RSSI buffer.put(0x11.toByte()); buffer.putInt(item.network.frequency) buffer.put(0x09.toByte()) // AP end } buffer.put(0x10.toByte()) // scan footer scanIndex++ } return buffer.array() }
Финальный результат: WiProber собирает в cacheDir всю JSON-часть (project.json, floorPlans.json, accessPoints.json, measuredRadios.json, surveys.json, accessPointMeasurements.json и компанию), складывает рядом файлы track-{surveyId}.bin с этим бинарным форматом и упаковывает всё в один zip с расширением .esx. Ekahau AI Pro открывает такой файл «как родной»: видит план, AP, привязанные к ним замеры, рисует heatmap’ы покрытия RSSI и SNR и показывает трек walking survey прямо поверх планов — ровно так же, как если бы проект изначально был сделан в Sidekick.
Разработка под Android: что было трудно и как это лечилось
Первый год я не только разбирал структуру файлов Ekahau и писал алгоритмы, но и боролся с ограничениями ОС — Android очень не любит, когда приложения часто сканируют сети. Если коротко, меня поджидали три засады: разрешения, scan throttling и режим Continuous, в котором нужно сканировать так часто, как только система разрешит, и при этом не разорвать текущую ассоциацию с точкой доступа. Расскажу про каждую.
1. Разрешения
Геолокация ради SSID. Первая проблема любого Wi‑Fi‑сканера на современном Android — получение из WifiManager.scanResults хоть чего‑либо кроме MAC‑адреса своей же сети, приложению нужно разрешение ACCESS_FINE_LOCATION плюс включённая системная служба геолокации. Без неё scanResults возвращает пустой список — даже если разрешение выдано. Поэтому в манифесте пришлось запросить и fine, и coarse, а в рантайме я делаю двухступенчатую проверку: сначала разрешения, потом — статус самого класса LocationManager, который предоставляет доступ к системным службам геолокации, и только после этого жму на «скан».
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
private fun checkPermissionsAndScan() { if (!WifiScanner.isWifiEnabled()) { /* открыть системную панель Wi-Fi */ return } val permissions = arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CHANGE_WIFI_STATE, Manifest.permission.ACCESS_WIFI_STATE ) val allGranted = permissions.all { ActivityCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } if (allGranted) { if (isLocationServiceEnabled()) scanWifi() else showLocationServiceDialog() } else { requestPermissionLauncher.launch(permissions) } }
Скан — асинхронный: вызов и результат разнесены во времени. Метод wifiManager.startScan() — обычная функция, но она лишь только говорит системе «попробуй посканировать» и сразу возвращает управление; результаты приходят позже как отдельный broadcast SCAN_RESULTS_AVAILABLE_ACTION. И здесь оговорюсь: в Android «broadcast» означает не сетевой broadcast‑пакет, а внутренний механизм рассылки сообщений между процессами: ОС публикует Intent, а все приложения, которые подписались на это событие через BroadcastReceiver, его получают. Так что наш сценарий устроен в два шага: дёрнули функцию startScan() — сидим ждём, пока система через broadcast не скажет, что результаты готовы.
Причём метод официально deprecated с API 28, но альтернативы для произвольного приложения нет: companion‑device API под радиообследование не годится. Поэтому я аккуратно зарегистрировал receiver — с учётом нового флага RECEIVER_NOT_EXPORTED, который Google потребовал начиная с Android 13:
private val wifiScanReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val success = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false) if (success && hasFineLocation(context)) { onScanResults?.invoke(wifiManager.scanResults) } else { onScanFailure?.invoke() } } } fun initialize(context: Context) { wifiManager = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager val filter = IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.registerReceiver(wifiScanReceiver, filter, Context.RECEIVER_NOT_EXPORTED) } else { @Suppress("DEPRECATION") context.registerReceiver(wifiScanReceiver, filter) } }
Дальше — главная боль.
2. Scan throttling
Начиная с Android 9, система ограничивает приложение четырьмя сканированиями за две минуты — и это касается даже foreground‑приложений. Для радиообследования, где инженер ходит по складу и ожидает 100+ замеров на маршруте, это приговор. Полностью обойти ограничение нельзя (это сделано осознанно, ради аккумулятора), но можно:
Честно определить, включён ли throttling, через
WifiManager.isScanThrottleEnabledна API 30+ либо черезSettings.Globalна более старых билдах.Аккуратно вести собственный учёт «свежих» сканов, чтобы предсказуемо сообщать пользователю, сколько секунд ему ждать.
Подсказать инженеру отключить throttle в «Параметрах разработчика» — на корпоративных Android‑смартфонах это легитимный и быстрый путь.
В WiProber я использовал все три подхода. Приложение само проверяет, включен ли throttle на устройстве, и ведёт собственное окно из timestamp последних четырёх сканов: пока в окне меньше четырех записей за две минуты — пускаю скан, иначе показываю сообщение с обратным отсчётом «подождите N секунд». А также отдельно подсказываю: один раз снять ограничение в разделе для разработчиков, и сканирование пойдет с аппаратной скоростью.
private fun isSystemThrottlingEnabled(): Boolean { val wifi = applicationContext .getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return wifi.isScanThrottleEnabled return try { Settings.Global.getInt(contentResolver, "wifi_scan_throttle_enabled") == 1 } catch (_: Exception) { true } } private fun canScan(): Boolean { if (!isSystemThrottlingEnabled()) return true val twoMinutesInMillis = 2 * 60 * 1000 val currentTime = System.currentTimeMillis() viewModel.recentScanTimestamps.removeAll { timestamp -> currentTime - timestamp > twoMinutesInMillis } if (viewModel.recentScanTimestamps.size < 4) { return true } else { val oldestRecentScan = viewModel.recentScanTimestamps.minOrNull() ?: currentTime val timeToWait = (oldestRecentScan + twoMinutesInMillis) - currentTime val secondsLeft = (timeToWait / 1000).toInt() + 1 if (currentTime - lastThrottlingToastTime > 5000) { showThrottlingLimitDialog(secondsLeft) lastThrottlingToastTime = currentTime } return false } }
3. Режим Continuous и нюанс с реассоциацией
В Stop‑and‑Go‑режиме перед каждым сканом я принудительно вызываю wifiManager.disconnect() — это даёт более точную картину эфира, потому что радиомодуль не «зацеплен» на текущую AP. Но в Continuous, где пользователь идёт по маршруту, рвать ассоциацию каждые несколько секунд недопустимо: и аккумулятор сядет, и работающие на телефоне сетевые сервисы будут страдать. Поэтому у startScan появился флаг shouldDisconnect, а цикл Continuous‑скана — это самозапланированный хвост: следующий запрос на скан стартует прямо из колбэка, как только пришли результаты, либо после короткой задержки в случае ошибки. Получается простая кооперативная петля на корутинах lifecycleScope, которую легко гасить флагом viewModel.isTracking.
WifiScanner.startScan( onResults = { results -> val duration = System.currentTimeMillis() - startTime viewModel.addContinuousScanResult(mapScanResults(results), duration) if (viewModel.isTracking.value == true) startContinuousScanningLoop() }, onFailure = { lifecycleScope.launch { delay(1000) if (viewModel.isTracking.value == true) startContinuousScanningLoop() } }, shouldDisconnect = false )
В сумме это даёт скорость скана, близкую к аппаратной, без обрыва соединения и без «зависающих» состояний UI, если пользователь резко завершил сессию.
Где разработчика зажимает iOS и как из этого выкручиваться
С «яблочной» техникой были свои заморочки: на iOS все «гайки закручены» — система не отдаёт детальную статистику сигнала сторонним приложениям (Sandbox).
На iOS архитектура ровно противоположна Android: системные API расскажут тебе про твою же ассоциацию, но не про эфир вокруг, а sandbox запрещает почти любое прямое общение с радиомодулем. Под капотом получилось три истории.
1. Откуда брать RSSI/Noise/MCS, если приложениям этого не дают
Apple убрала CNCopyCurrentNetworkInfo для большинства сценариев, а более низкоуровневых публичных API для статистики Wi‑Fi у third‑party‑приложений просто нет. Зато есть Shortcuts: системное действие Get Network Details видит и WiFiSig, и WiFiNoise, и WiFiTxRate/RxRate, и стандарт, и BSSID.
Так и родилась связка: пользователь один раз ставит шорткат, который циклом читает параметры сети и через App Intents «стримит» их обратно в приложение. App Intent на стороне Swift получает JSON‑файл от шортката, парсит и публикует точку в реактивный поток Combine, на который уже подписана ViewModel и графики SwiftUI.
struct StreamDataToApp: AppIntent { static var title: LocalizedStringResource = "Stream Data to WiFi Monitor" static var openAppWhenRun: Bool = false @Parameter(title: "WiFi Parameters") var wifiParametersFile: IntentFile @MainActor func perform() async throws -> some IntentResult { let data = wifiParametersFile.data guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw IntentError.parsingFailed } let point = WiFiDataPoint( id: UUID(), timestamp: Date(), rssi: dict["WiFiSig"] as? Int ?? 0, noise: dict["WiFiNoise"] as? Int ?? 0, standard:dict["WiFiStd"] as? String ?? "N/A", channel: dict["WiFiCh"] as? Int ?? 0, txRate: (dict["WiFiTxRate"] as? NSNumber)?.doubleValue ?? 0, rxRate: (dict["WiFiRxRate"] as? NSNumber)?.doubleValue ?? 0, ssid: dict["WiFiName"] as? String ?? "N/A", bssid: dict["WiFiBSSID"] as? String ?? "N/A" ) DataStream.subject.send(point) return .result() } }
Обратный канал — «пора остановиться» — сделан как ещё один интент ShouldStopMonitoring, читающий флаг из общего App Group UserDefaults. Шорткат опрашивает его на каждой итерации и сам корректно выходит из цикла, как только пользователь нажал «Stop» в приложении. Получается полудуплексная связка без нативного фонового сервиса (которого приложению‑инструменту в iOS никто и не даст).
2. Roaming Log на коленке
Раз уж точки сигнала всё равно прилетают в поток, ловить роуминг — это просто сравнение текущего BSSID с предыдущим. Событие фиксируется только если BSSID реально сменился и оба значения не пустые. К событию я тут же кладу RSSI «до/после» и канал — этого хватает, чтобы потом в CSV‑отчёте увидеть, где клиент «прыгнул» с диапазона 5 GHz на 2.4 GHz, и понять, не залип ли он на дальней AP.
if let last = lastBSSID, !last.isEmpty, !point.bssid.isEmpty, last != point.bssid { let event = RoamingEvent( timestamp: point.timestamp, fromBSSID: last, toBSSID: point.bssid, fromRSSI: lastRSSI ?? 0, toRSSI: point.rssi, fromChannel: lastChannel ?? 0, toChannel: point.channel ) roamingEvents.insert(event, at: 0) if roamingEvents.count > 50 { roamingEvents.removeLast() } } lastBSSID = point.bssid lastRSSI = point.rssi lastChannel = point.channel
3. Latency Monitor: TCP-пинг и поиск IP-адреса шлюза
ICMP‑сокеты на iOS требуют либо raw‑сокетов, либо стороннего SimplePing — и то и другое для инструмента из App Store избыточно. Я обошёлся TCP‑пингом через Network.framework: измеряю время от старта NWConnection до состояния .ready на 443/53/80 — это близкая к реальной latency, потому что в неё входит и SYN/SYN‑ACK, и TLS‑инициализация при HTTPS. Чтобы пинговать оба адреса параллельно — внешний (ya.ru / 77.88.44.242) и внутренний шлюз, — нужен сам IP-адрес шлюза. CoreLocation/NetworkExtension его не отдают, поэтому пришлось спуститься в Darwin и аккуратно прочитать таблицу маршрутов через sysctl(CTL_NET, PF_ROUTE, NET_RT_FLAGS, …). Кусок самый «СИшный» во всём проекте, но обёрнут он буквально в одну функцию GatewayTools.getGateways(), возвращающую IPv4- и IPv6-адреса.
static func ping(host: String, port: UInt16 = 443) async -> Double? { let start = ProcessInfo.processInfo.systemUptime return await withCheckedContinuation { cont in let endpoint = NWEndpoint.hostPort( host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port)) let conn = NWConnection(to: endpoint, using: .tcp) let lock = OSAllocatedUnfairLock(initialState: false) let finish = { (rtt: Double?) in let go = lock.withLock { resumed -> Bool in if resumed { return false } resumed = true; return true } if go { conn.cancel(); cont.resume(returning: rtt) } } conn.stateUpdateHandler = { state in switch state { case .ready: finish((ProcessInfo.processInfo.systemUptime - start) * 1000) case .failed, .cancelled: finish(nil) default: break } } conn.start(queue: .global()) DispatchQueue.global().asyncAfter(deadline: .now() + 2) { finish(nil) } } }
private static func getGateway(family: Int32) -> String? { var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, family, NET_RT_FLAGS, RTF_GATEWAY] var len = 0 if sysctl(&mib, 6, nil, &len, nil, 0) < 0 { return nil } var buf = [UInt8](repeating: 0, count: len) if sysctl(&mib, 6, &buf, &len, nil, 0) < 0 { return nil } // … парсим RTM-сообщения и достаём адрес шлюза через getnameinfo() }
В результате без приватных API получился рабочий «карманный» инструмент: графики RSSI/Noise — через Shortcuts‑мост, roaming‑лог — поверх потока данных, ping и AR‑режим — нативно. С точки зрения App Store‑ревью это абсолютно «белая» комбинация: я не лезу в радиомодуль, я лишь красиво пакую и визуализирую то, что система согласна мне дать.
Статус проекта
Приложение прошло ревью Apple и доступно в App Store. Инструменты типа Ping и AR работают сразу, а для работы графиков нужно будет один раз из приложения нажать кнопку установки вспомогательного шортката.
WiProber под Android выложен в опенсорсе. Буду рад, если инструмент пригодится в работе или личных целях и вам. Исходный код открыт — заходите, смотрите, приносите идеи или сразу пул‑реквесты, если знакомы с Kotlin.
Ссылка на App Store: https://apps.apple.com/ru/app/wifi-prober/id6756080589
Ссылка на APK и репозиторий: https://github.com/htechno/WiProber
А ещё больше про разработки Yandex Infrastructure — в канале нашей команды.
Комментарии (6)

Belkau77
05.06.2026 07:39100% пригодится. сохранил себе.
п.с. не было идей запилить или изолировать какие то функции на платной основе?
гипотетически спрашиваю, очень полезный инструмент явно...
htechno Автор
05.06.2026 07:39Была такая мимолетная идея, но я её сразу отмёл. Целевая аудитория слишком маленькая чтобы заработать, есть подобные бесплатные решения, это мой Pet-проект для работы, а не способ заработка.
Tirarex
Этакий Ubiquiti wifiman но который не умеет строить карты в AR режиме.
htechno Автор
WiFiMan классное приложение. Правда не всегда стабильно работает, часты вылетает, ну да ладно. Я добавлял в приложения те функции, которые нужны именно мне как WiFi инженеру в своей работе.
exhalace
WiFiMan умеет выгружать результаты в *.esx (Ekahau) формат?