Add ADB-over-Wi-Fi failover and fix screenshot/reconnect performance
This commit is contained in:
parent
3c1db3c36c
commit
5ea0b42804
@ -38,6 +38,7 @@ cmake --build .
|
|||||||
| [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт |
|
| [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт |
|
||||||
| [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) |
|
| [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) |
|
||||||
| [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса |
|
| [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса |
|
||||||
|
| [docs/wifi-adb.md](docs/wifi-adb.md) | Автоматический ADB-over-Wi-Fi: USB-приоритет, Wi-Fi как горячий резерв |
|
||||||
| [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) |
|
| [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) |
|
||||||
| [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon |
|
| [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon |
|
||||||
|
|
||||||
|
|||||||
@ -540,7 +540,10 @@ QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) {
|
|||||||
<< "error:" << process.errorString();
|
<< "error:" << process.errorString();
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if (!process.waitForFinished(10000) || process.exitCode() != 0) {
|
// По Wi-Fi (ip:port) exec-out screencap стримит PNG через сеть медленнее, чем по
|
||||||
|
// USB — даём больше времени, иначе кадр обрывается «пустым». По USB оставляем 10с.
|
||||||
|
const int timeoutMs = isWirelessSerial(deviceId) ? 12000 : 10000;
|
||||||
|
if (!process.waitForFinished(timeoutMs) || process.exitCode() != 0) {
|
||||||
qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId;
|
qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId;
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -674,15 +677,20 @@ void AdbUtils::kickArcShot(const QString &deviceId) {
|
|||||||
qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed();
|
qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Доступ к /sdcard без диалога на устройстве.
|
|
||||||
startProcess(adb, {"-s", deviceId, "shell", "appops", "set",
|
|
||||||
"com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"});
|
|
||||||
qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId;
|
qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде
|
// 3a. Доступ к /sdcard — БЕЗУСЛОВНО (а не только при установке): файл скриншота
|
||||||
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор
|
// создаёт adb screencap как root:everybody (0660), и без MANAGE_EXTERNAL_STORAGE
|
||||||
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает.
|
// сервис не может его прочитать — readFile() кидает IOException, соединение
|
||||||
|
// закрывается без HTTP-ответа (/health при этом отвечает). Для уже установленного
|
||||||
|
// arc-shot грант раньше не выполнялся — отсюда вечный фейл http-скриншота.
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "appops", "set",
|
||||||
|
"com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"});
|
||||||
|
|
||||||
|
// 3b. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде
|
||||||
|
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор
|
||||||
|
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает.
|
||||||
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist",
|
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist",
|
||||||
"+com.arc.shot"});
|
"+com.arc.shot"});
|
||||||
startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot",
|
startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot",
|
||||||
@ -1121,3 +1129,159 @@ void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, cons
|
|||||||
qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId;
|
qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ADB через Wi-Fi (TCP/IP) ---
|
||||||
|
|
||||||
|
bool AdbUtils::isWirelessSerial(const QString &serial) {
|
||||||
|
// Сетевой endpoint вида "192.168.1.50:5555". USB-серийники — hex без точек,
|
||||||
|
// эмулятор — "emulator-5554" (есть дефис, нет точек/двоеточия с ip).
|
||||||
|
static const QRegularExpression re(R"(^\d{1,3}(\.\d{1,3}){3}:\d+$)");
|
||||||
|
return re.match(serial).hasMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::getDeviceWifiIp(const QString &serial) {
|
||||||
|
if (serial.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
// Основной путь: ip-адрес интерфейса wlan0.
|
||||||
|
// inet 192.168.1.50/24 brd ... scope global wlan0
|
||||||
|
QString out;
|
||||||
|
if (startProcess(adb, {"-s", serial, "shell", "ip", "-f", "inet", "addr", "show", "wlan0"}, &out)) {
|
||||||
|
static const QRegularExpression reInet(R"(inet\s+(\d{1,3}(?:\.\d{1,3}){3}))");
|
||||||
|
if (const auto m = reInet.match(out); m.hasMatch()) {
|
||||||
|
return m.captured(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фолбэк: маршрут по умолчанию через wlan0 (часть прошивок не отдаёт `ip addr`).
|
||||||
|
// 192.168.1.0/24 dev wlan0 ... src 192.168.1.50
|
||||||
|
out.clear();
|
||||||
|
if (startProcess(adb, {"-s", serial, "shell", "ip", "route"}, &out)) {
|
||||||
|
static const QRegularExpression reSrc(R"(wlan0[^\n]*\bsrc\s+(\d{1,3}(?:\.\d{1,3}){3}))");
|
||||||
|
if (const auto m = reSrc.match(out); m.hasMatch()) {
|
||||||
|
return m.captured(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::getDeviceMac(const QString &serial) {
|
||||||
|
if (serial.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
// Основной путь: `ip addr show wlan0` — берём строку link/ether. Это та же команда,
|
||||||
|
// что и для IP в getDeviceWifiIp, и она проходит без root, включая MIUI/HyperOS
|
||||||
|
// (где `cat /sys/.../address` и `ip link show` отдают Permission denied).
|
||||||
|
// Прим.: на Android 10+ это обычно рандомизированный per-SSID MAC, но он стабилен
|
||||||
|
// для данной сети и именно его видит ARP-таблица ПК — что нам и нужно для поиска.
|
||||||
|
QString out;
|
||||||
|
if (startProcess(adb, {"-s", serial, "shell", "ip", "addr", "show", "wlan0"}, &out)) {
|
||||||
|
static const QRegularExpression reEther(
|
||||||
|
R"(link/ether\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}))");
|
||||||
|
if (const auto m = reEther.match(out); m.hasMatch()) {
|
||||||
|
return m.captured(1).toLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фолбэк: `cmd wifi status` → строка "MAC: aa:bb:cc:dd:ee:ff".
|
||||||
|
out.clear();
|
||||||
|
if (startProcess(adb, {"-s", serial, "shell", "cmd", "wifi", "status"}, &out)) {
|
||||||
|
static const QRegularExpression reWifiMac(
|
||||||
|
R"(MAC:\s*([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}))");
|
||||||
|
if (const auto m = reWifiMac.match(out); m.hasMatch()) {
|
||||||
|
return m.captured(1).toLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Старые методы не проходят без root на MIUI (Permission denied), оставлены в
|
||||||
|
// истории: cat /sys/class/net/wlan0/address ; ip link show wlan0
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::enableTcpip(const QString &serial, const int port) {
|
||||||
|
if (serial.isEmpty() || port <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QString out, err;
|
||||||
|
// adb tcpip перезапускает adbd на устройстве в TCP-режиме. Печатает
|
||||||
|
// "restarting in TCP mode port: <port>". Иногда возвращает ненулевой код,
|
||||||
|
// хотя режим переключён — поэтому считаем успехом и exit 0, и наличие маркера.
|
||||||
|
const bool ok = startProcess(adbPath(), {"-s", serial, "tcpip", QString::number(port)}, &out, &err);
|
||||||
|
const bool restarted = out.contains("restarting", Qt::CaseInsensitive)
|
||||||
|
|| err.contains("restarting", Qt::CaseInsensitive);
|
||||||
|
if (!ok && !restarted) {
|
||||||
|
qWarning() << "[AdbUtils] enableTcpip failed for" << serial << "port" << port
|
||||||
|
<< "out:" << out.trimmed() << "err:" << err.trimmed();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qInfo() << "[AdbUtils] enableTcpip ok for" << serial << "port" << port << ":" << out.trimmed();
|
||||||
|
// adbd перезапускается не мгновенно — даём ему подняться перед adb connect.
|
||||||
|
QThread::msleep(1500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::connectWireless(const QString &endpoint) {
|
||||||
|
if (endpoint.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QProcess process;
|
||||||
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {"connect", endpoint});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] connectWireless failed to start for" << endpoint
|
||||||
|
<< "error:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
waitOrKill(process, 10000, "connectWireless");
|
||||||
|
const QString out = QString::fromUtf8(process.readAllStandardOutput()).trimmed();
|
||||||
|
|
||||||
|
// Текст вывода — лишь предварительный фильтр: явный провал ("cannot connect ...",
|
||||||
|
// "failed to connect ...") отсекаем сразу. В остальных случаях (включая лукавое
|
||||||
|
// "already connected to ...") подтверждаем успех ПО ФАКТУ: эндпоинт должен реально
|
||||||
|
// перейти в статус "device" в `adb devices`. Это лечит ложный успех на залипшем
|
||||||
|
// offline-эндпоинте — голый connect его не поднимает, и теперь мы это видим.
|
||||||
|
if (out.contains("cannot", Qt::CaseInsensitive)
|
||||||
|
|| out.contains("failed", Qt::CaseInsensitive)) {
|
||||||
|
qWarning() << "[AdbUtils] connectWireless" << endpoint << "→" << out;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Эндпоинту даём несколько секунд «дозреть» (offline/connecting → device),
|
||||||
|
// опрашивая adb devices. Здоровый эндпоинт пройдёт уже на первой итерации.
|
||||||
|
for (int attempt = 0; attempt < 4; ++attempt) {
|
||||||
|
for (const auto &[serial, status] : getListDevices()) {
|
||||||
|
if (serial != endpoint) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status == "device") {
|
||||||
|
qInfo() << "[AdbUtils] connectWireless" << endpoint << "→ device (" << out << ")";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break; // эндпоинт есть, но ещё offline/connecting — ждём следующую итерацию
|
||||||
|
}
|
||||||
|
QThread::msleep(800);
|
||||||
|
}
|
||||||
|
qWarning() << "[AdbUtils] connectWireless" << endpoint
|
||||||
|
<< "→ не перешёл в 'device' после connect (" << out << ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::disconnectWireless(const QString &endpoint) {
|
||||||
|
if (endpoint.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QProcess process;
|
||||||
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {"disconnect", endpoint});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] disconnectWireless failed to start for" << endpoint
|
||||||
|
<< "error:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
waitOrKill(process, 10000, "disconnectWireless");
|
||||||
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -86,6 +86,34 @@ public:
|
|||||||
// экран в портрете (изначально или после форсирования).
|
// экран в портрете (изначально или после форсирования).
|
||||||
static bool ensurePortraitOrientation(const QString &deviceId);
|
static bool ensurePortraitOrientation(const QString &deviceId);
|
||||||
|
|
||||||
|
// --- ADB через Wi-Fi (TCP/IP) ---
|
||||||
|
|
||||||
|
// Эвристика: serial выглядит как сетевой endpoint "ip:port" (Wi-Fi-подключение),
|
||||||
|
// а не USB-серийник (hex) или emulator-XXXX.
|
||||||
|
static bool isWirelessSerial(const QString &serial);
|
||||||
|
|
||||||
|
// Возвращает IPv4-адрес устройства в сети Wi-Fi (интерфейс wlan0), либо пустую
|
||||||
|
// строку, если устройство не в Wi-Fi / адрес не прочитался.
|
||||||
|
static QString getDeviceWifiIp(const QString &serial);
|
||||||
|
|
||||||
|
// Возвращает MAC-адрес интерфейса wlan0 в нормализованном виде (lower-case,
|
||||||
|
// разделитель ':'), либо пустую строку. Нужен, чтобы находить устройство в сети
|
||||||
|
// после смены IP по DHCP (поиск по ARP-таблице).
|
||||||
|
static QString getDeviceMac(const QString &serial);
|
||||||
|
|
||||||
|
// Переводит adbd устройства в TCP-режим на указанном порту (adb -s <serial> tcpip <port>).
|
||||||
|
// Вызывается на устройстве, подключённом по USB. ВНИМАНИЕ: перезапускает adbd и
|
||||||
|
// кратковременно рвёт текущее соединение — запускать только на простаивающем устройстве.
|
||||||
|
static bool enableTcpip(const QString &serial, int port);
|
||||||
|
|
||||||
|
// adb connect <endpoint> (endpoint вида "192.168.1.50:5555").
|
||||||
|
// Возвращает true при "connected"/"already connected". Exit-code adb недостаточен —
|
||||||
|
// adb connect возвращает 0 даже при "failed to connect", поэтому разбираем stdout.
|
||||||
|
static bool connectWireless(const QString &endpoint);
|
||||||
|
|
||||||
|
// adb disconnect <endpoint>.
|
||||||
|
static bool disconnectWireless(const QString &endpoint);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static QString adbDir();
|
static QString adbDir();
|
||||||
static bool startProcess(const QString &program, const QStringList &args,
|
static bool startProcess(const QString &program, const QStringList &args,
|
||||||
|
|||||||
299
android/adb/WifiAdbManager.cpp
Normal file
299
android/adb/WifiAdbManager.cpp
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
#include "WifiAdbManager.h"
|
||||||
|
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
#include <QTcpSocket>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/WifiConnectionDAO.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Быстрая TCP-проба достижимости эндпоинта МИМО adb: `adb connect` к мёртвому хосту
|
||||||
|
// висит до 10с, поэтому сперва открываем сокет с коротким таймаутом. Без event-loop —
|
||||||
|
// безопасно из фонового потока (как AdbUtils::httpGet).
|
||||||
|
constexpr int kReachProbeMs = 600;
|
||||||
|
|
||||||
|
bool tcpReachable(const QString &ip, int port, int timeoutMs) {
|
||||||
|
if (ip.isEmpty() || port <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QTcpSocket sock;
|
||||||
|
sock.connectToHost(ip, static_cast<quint16>(port));
|
||||||
|
const bool ok = sock.waitForConnected(timeoutMs);
|
||||||
|
sock.abort();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пер-эндпоинт backoff: офлайн-устройство не долбим каждый проход 10-секундными
|
||||||
|
// connect'ами. Задержка растёт 30→60→120→240→300с (потолок) по числу неудач подряд,
|
||||||
|
// сбрасывается при успехе/онлайне. Состояние живёт только в потоке DeviceScreener
|
||||||
|
// (reconnectKnown/kickEndpoint зовутся лишь оттуда) — без мьютекса.
|
||||||
|
constexpr qint64 kBackoffBaseSecs = 30;
|
||||||
|
constexpr qint64 kBackoffMaxSecs = 300;
|
||||||
|
QHash<QString, int> g_failCount; // androidId → неудач подряд
|
||||||
|
QHash<QString, qint64> g_nextRetryAt; // androidId → unix ts, до которого пропускаем
|
||||||
|
|
||||||
|
void noteFailure(const QString &androidId) {
|
||||||
|
const int n = ++g_failCount[androidId];
|
||||||
|
const qint64 delay = qMin(kBackoffBaseSecs * (1LL << qMin(n - 1, 4)), kBackoffMaxSecs);
|
||||||
|
g_nextRetryAt[androidId] = QDateTime::currentSecsSinceEpoch() + delay;
|
||||||
|
}
|
||||||
|
void noteSuccess(const QString &androidId) {
|
||||||
|
g_failCount.remove(androidId);
|
||||||
|
g_nextRetryAt.remove(androidId);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QMap<QString, WifiEndpoint> WifiAdbManager::loadEndpoints() {
|
||||||
|
QMap<QString, WifiEndpoint> result;
|
||||||
|
for (const WifiConnection &wc : WifiConnectionDAO::getAll()) {
|
||||||
|
if (wc.androidId.isEmpty() || wc.ip.isEmpty() || wc.port <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
WifiEndpoint ep;
|
||||||
|
ep.endpoint = wc.ip + ":" + QString::number(wc.port);
|
||||||
|
ep.mac = wc.mac;
|
||||||
|
result.insert(wc.androidId, ep);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiAdbManager::saveEndpoint(const QString &androidId, const QString &endpoint, const QString &mac) {
|
||||||
|
if (androidId.isEmpty() || endpoint.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const WifiConnection existing = WifiConnectionDAO::getByAndroidId(androidId);
|
||||||
|
// MAC мог не прочитаться при этом вызове — тогда не затираем уже сохранённый.
|
||||||
|
const QString mergedMac = !mac.isEmpty() ? mac : existing.mac;
|
||||||
|
const QString ip = ipOf(endpoint);
|
||||||
|
const QString port = portOf(endpoint);
|
||||||
|
if (ip.isEmpty() || port.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString existingEndpoint = existing.ip.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: existing.ip + ":" + QString::number(existing.port);
|
||||||
|
if (existingEndpoint == endpoint && existing.mac == mergedMac) {
|
||||||
|
return; // ничего не изменилось — не дёргаем БД
|
||||||
|
}
|
||||||
|
// code не трогаем (задел под pairing) — WifiAdbManager его не использует, пишем "".
|
||||||
|
if (WifiConnectionDAO::upsert(androidId, ip, port.toInt(), {}, mergedMac)) {
|
||||||
|
qInfo() << "[WifiAdbManager] saved endpoint" << endpoint << "mac" << mergedMac
|
||||||
|
<< "for device" << androidId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int WifiAdbManager::reconnectKnown(const ReconnectOptions &opts) {
|
||||||
|
const QMap<QString, WifiEndpoint> endpoints = loadEndpoints();
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
int connected = 0;
|
||||||
|
for (auto it = endpoints.constBegin(); it != endpoints.constEnd(); ++it) {
|
||||||
|
const QString &androidId = it.key();
|
||||||
|
const WifiEndpoint &ep = it.value();
|
||||||
|
// Устройство уже онлайн (по USB или по этому же Wi-Fi-эндпоинту) — не дёргаем,
|
||||||
|
// сбрасываем счётчик неудач (вернулось — backoff больше не нужен).
|
||||||
|
if (opts.alreadyOnline.contains(androidId)) {
|
||||||
|
noteSuccess(androidId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// п.2 — пер-эндпоинт backoff: мёртвый девайс не долбим каждый проход.
|
||||||
|
if (now < g_nextRetryAt.value(androidId, 0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// п.1 — быстрая TCP-проба сохранённого ip:port вместо 10-секундного adb connect
|
||||||
|
// к мёртвому хосту. Достижим → пробуем реальный connect (с disconnect для stale).
|
||||||
|
bool ok = false;
|
||||||
|
if (tcpReachable(ipOf(ep.endpoint), portOf(ep.endpoint).toInt(), kReachProbeMs)) {
|
||||||
|
// Протухший offline-эндпоинт: adb держит запись, но канал мёртв. Голый connect
|
||||||
|
// вернул бы "already connected" — сначала disconnect, затем чистый connect.
|
||||||
|
if (opts.staleEndpoints.contains(ep.endpoint)) {
|
||||||
|
AdbUtils::disconnectWireless(ep.endpoint);
|
||||||
|
}
|
||||||
|
ok = AdbUtils::connectWireless(ep.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранённый IP недостижим/connect не поднялся — возможно, адрес сменился по DHCP.
|
||||||
|
// Если знаем MAC, ищем устройство в сети по нему и переподключаемся на новый IP.
|
||||||
|
if (!ok && opts.macRediscover && !ep.mac.isEmpty()) {
|
||||||
|
const QString newIp = findIpByMac(ep.mac, ipOf(ep.endpoint), opts.subnetSweep);
|
||||||
|
if (!newIp.isEmpty()) {
|
||||||
|
const QString newEndpoint = newIp + ":" + portOf(ep.endpoint);
|
||||||
|
if (newEndpoint != ep.endpoint
|
||||||
|
&& tcpReachable(newIp, portOf(ep.endpoint).toInt(), kReachProbeMs)
|
||||||
|
&& AdbUtils::connectWireless(newEndpoint)) {
|
||||||
|
saveEndpoint(androidId, newEndpoint, ep.mac);
|
||||||
|
qInfo() << "[WifiAdbManager] device" << androidId << "moved"
|
||||||
|
<< ep.endpoint << "→" << newEndpoint << "(found by MAC)";
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
noteSuccess(androidId);
|
||||||
|
++connected;
|
||||||
|
} else {
|
||||||
|
// Неудача не критична (устройство выключено / потеряло tcpip после ребута):
|
||||||
|
// эндпоинт оставляем, наращиваем backoff, чтобы не блокировать цикл опроса.
|
||||||
|
noteFailure(androidId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WifiAdbManager::kickEndpoint(const QString &androidId) {
|
||||||
|
if (androidId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const WifiConnection wc = WifiConnectionDAO::getByAndroidId(androidId);
|
||||||
|
if (wc.ip.isEmpty() || wc.port <= 0) {
|
||||||
|
return false; // нет Wi-Fi-эндпоинта — офлайн не по нашей части (например, USB)
|
||||||
|
}
|
||||||
|
const QString endpoint = wc.ip + ":" + QString::number(wc.port);
|
||||||
|
// Быстрая проба (п.1): если порт недостижим — пересоздавать нечего, не вешаем цикл
|
||||||
|
// на 10с adb disconnect/connect. У «залипшего» эндпоинта adbd-порт обычно ещё слушает.
|
||||||
|
if (!tcpReachable(wc.ip, wc.port, kReachProbeMs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Полумёртвое соединение adb держит как "device", но команды виснут; голый connect
|
||||||
|
// вернул бы "already connected" и не вылечил. Принудительно пересоздаём TCP.
|
||||||
|
AdbUtils::disconnectWireless(endpoint);
|
||||||
|
const bool ok = AdbUtils::connectWireless(endpoint);
|
||||||
|
qInfo() << "[WifiAdbManager] kicked wedged endpoint" << endpoint
|
||||||
|
<< "for device" << androidId << "→" << (ok ? "reconnected" : "still down");
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WifiAdbManager::tryBootstrap(const QString &usbSerial, const QString &androidId, const int port) {
|
||||||
|
if (usbSerial.isEmpty() || androidId.isEmpty() || port <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// Сначала узнаём IP по Wi-Fi: если устройство не в Wi-Fi — нет смысла дёргать
|
||||||
|
// tcpip (он бы зря порвал adbd). Делаем это ДО tcpip.
|
||||||
|
const QString ip = AdbUtils::getDeviceWifiIp(usbSerial);
|
||||||
|
if (ip.isEmpty()) {
|
||||||
|
qInfo() << "[WifiAdbManager] device" << androidId << "(" << usbSerial
|
||||||
|
<< ") has no wlan0 IP — skip Wi-Fi bootstrap";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QString endpoint = ip + ":" + QString::number(port);
|
||||||
|
|
||||||
|
// MAC снимаем по USB (до tcpip) — пригодится для поиска устройства при смене IP.
|
||||||
|
const QString mac = AdbUtils::getDeviceMac(usbSerial);
|
||||||
|
|
||||||
|
// Переключаем adbd в TCP-режим (рвёт текущее соединение — вызывающий гарантирует,
|
||||||
|
// что устройство простаивает) и подключаемся по сети.
|
||||||
|
if (!AdbUtils::enableTcpip(usbSerial, port)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!AdbUtils::connectWireless(endpoint)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEndpoint(androidId, endpoint, mac);
|
||||||
|
qInfo() << "[WifiAdbManager] Wi-Fi endpoint up for device" << androidId << "→" << endpoint;
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiAdbManager::forget(const QString &androidId) {
|
||||||
|
if (androidId.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const WifiConnection wc = WifiConnectionDAO::getByAndroidId(androidId);
|
||||||
|
if (wc.ip.isEmpty() || wc.port <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AdbUtils::disconnectWireless(wc.ip + ":" + QString::number(wc.port));
|
||||||
|
WifiConnectionDAO::remove(androidId);
|
||||||
|
qInfo() << "[WifiAdbManager] forgot Wi-Fi endpoint for device" << androidId;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WifiAdbManager::findIpByMac(const QString &mac, const QString &subnetHintIp, const bool allowSweep) {
|
||||||
|
const QString norm = normalizeMac(mac);
|
||||||
|
if (norm.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// Быстрый путь: устройство уже есть в ARP-таблице ОС (недавно общались).
|
||||||
|
QString ip = arpLookupIp(norm);
|
||||||
|
if (!ip.isEmpty()) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
// ARP-таблица не содержит устройство — опционально прогреваем её ping-сканом
|
||||||
|
// подсети /24 (медленно и шумно, поэтому только по явному флагу), затем повторяем.
|
||||||
|
if (allowSweep && !subnetHintIp.isEmpty()) {
|
||||||
|
pingSweepSubnet(subnetHintIp);
|
||||||
|
ip = arpLookupIp(norm);
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WifiAdbManager::arpLookupIp(const QString &normalizedMac) {
|
||||||
|
QProcess arp;
|
||||||
|
arp.start("arp", {"-a"});
|
||||||
|
if (!arp.waitForStarted(3000)) {
|
||||||
|
qWarning() << "[WifiAdbManager] failed to start arp:" << arp.errorString();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
arp.waitForFinished(8000);
|
||||||
|
const QString out = QString::fromLocal8Bit(arp.readAllStandardOutput());
|
||||||
|
// Строки вида (Windows): " 192.168.1.50 aa-bb-cc-dd-ee-ff dynamic"
|
||||||
|
// (unix): "? (192.168.1.50) at aa:bb:cc:dd:ee:ff [ether] on en0"
|
||||||
|
static const QRegularExpression reRow(
|
||||||
|
R"((\d{1,3}(?:\.\d{1,3}){3})\D+?([0-9a-fA-F]{2}(?:[-:][0-9a-fA-F]{2}){5}))");
|
||||||
|
auto it = reRow.globalMatch(out);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
const auto m = it.next();
|
||||||
|
if (normalizeMac(m.captured(2)) == normalizedMac) {
|
||||||
|
return m.captured(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiAdbManager::pingSweepSubnet(const QString &subnetHintIp) {
|
||||||
|
// /24 из hint-IP: 192.168.1.X → пинги по 192.168.1.{1..254}.
|
||||||
|
const int lastDot = subnetHintIp.lastIndexOf('.');
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString prefix = subnetHintIp.left(lastDot + 1);
|
||||||
|
QStringList hosts;
|
||||||
|
for (int i = 1; i <= 254; ++i) {
|
||||||
|
hosts << prefix + QString::number(i);
|
||||||
|
}
|
||||||
|
qInfo() << "[WifiAdbManager] ARP warm-up: ping sweep of" << prefix + "0/24";
|
||||||
|
// Параллельно (глобальный пул QtConcurrent), чтобы не блокировать цикл надолго.
|
||||||
|
// Ответ нам не важен — нужно лишь, чтобы ОС записала MAC соседей в ARP-таблицу.
|
||||||
|
QtConcurrent::blockingMap(hosts, [](const QString &host) {
|
||||||
|
QProcess p;
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
p.start("ping", {"-n", "1", "-w", "300", host});
|
||||||
|
#else
|
||||||
|
p.start("ping", {"-c", "1", "-W", "1", host});
|
||||||
|
#endif
|
||||||
|
if (p.waitForStarted(2000)) {
|
||||||
|
p.waitForFinished(2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WifiAdbManager::normalizeMac(const QString &mac) {
|
||||||
|
QString m = mac.trimmed().toLower();
|
||||||
|
m.replace('-', ':');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WifiAdbManager::ipOf(const QString &endpoint) {
|
||||||
|
const int c = endpoint.lastIndexOf(':');
|
||||||
|
return c > 0 ? endpoint.left(c) : endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WifiAdbManager::portOf(const QString &endpoint) {
|
||||||
|
const int c = endpoint.lastIndexOf(':');
|
||||||
|
return c > 0 ? endpoint.mid(c + 1) : QStringLiteral("5555");
|
||||||
|
}
|
||||||
72
android/adb/WifiAdbManager.h
Normal file
72
android/adb/WifiAdbManager.h
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMap>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Автоматическое поднятие/восстановление беспроводного (Wi-Fi, ADB over TCP/IP)
|
||||||
|
// подключения к Android-устройствам. Без участия оператора: USB остаётся основным
|
||||||
|
// транспортом, Wi-Fi — горячий резерв, который подхватывается при отключении кабеля.
|
||||||
|
//
|
||||||
|
// Сохранённые эндпоинты хранятся в реляционной таблице wifi_connections
|
||||||
|
// (WifiConnectionDAO), по строке на android_id: ip/port (= endpoint "ip:port") + mac.
|
||||||
|
// MAC нужен, чтобы найти устройство после смены IP по DHCP (поиск в ARP-таблице).
|
||||||
|
// WifiAdbManager — единственный владелец записи в этой таблице.
|
||||||
|
//
|
||||||
|
// Все методы статические и блокирующие (дёргают adb/arp/ping через QProcess) —
|
||||||
|
// вызывать из фонового потока (DeviceScreener), не из GUI-потока.
|
||||||
|
|
||||||
|
// Сохранённый беспроводной эндпоинт устройства.
|
||||||
|
struct WifiEndpoint {
|
||||||
|
QString endpoint; // "ip:port"
|
||||||
|
QString mac; // нормализованный MAC (lower-case, разделитель ':'); может быть пустым
|
||||||
|
};
|
||||||
|
|
||||||
|
class WifiAdbManager {
|
||||||
|
public:
|
||||||
|
// Параметры одного прохода переподключения сохранённых эндпоинтов.
|
||||||
|
struct ReconnectOptions {
|
||||||
|
QSet<QString> alreadyOnline; // android_id, уже видимые онлайн — пропускаем
|
||||||
|
QSet<QString> staleEndpoints; // "ip:port" в статусе offline — disconnect перед connect
|
||||||
|
bool macRediscover = false; // при неудаче искать новый IP устройства по MAC
|
||||||
|
bool subnetSweep = false; // разрешить ping-скан подсети для прогрева ARP (медленно/шумно)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохранённые эндпоинты: android_id → WifiEndpoint.
|
||||||
|
static QMap<QString, WifiEndpoint> loadEndpoints();
|
||||||
|
|
||||||
|
// Пытается `adb connect` по всем сохранённым эндпоинтам (для устройств, которых
|
||||||
|
// ещё нет онлайн). Возвращает число успешно подключённых.
|
||||||
|
static int reconnectKnown(const ReconnectOptions &opts);
|
||||||
|
|
||||||
|
// Принудительно пересоздаёт TCP-соединение Wi-Fi-эндпоинта устройства
|
||||||
|
// (disconnect → connect). Нужен, когда соединение «залипло» в полумёртвом
|
||||||
|
// состоянии: adb держит эндпоинт как "device", но команды к нему виснут (устройство
|
||||||
|
// уходит в debounced-offline, при этом в `adb devices` НЕ появляется статус
|
||||||
|
// "offline"). Голый connect в этом случае вернул бы "already connected" и не помог.
|
||||||
|
// No-op, если у устройства нет сохранённого Wi-Fi-эндпоинта (например, офлайн по USB).
|
||||||
|
// Возвращает true, если после переподключения соединение поднялось.
|
||||||
|
static bool kickEndpoint(const QString &androidId);
|
||||||
|
|
||||||
|
// Bootstrap беспроводного эндпоинта для устройства, подключённого по USB:
|
||||||
|
// снять MAC → tcpip <port> → определить wlan0 IP → adb connect → сохранить эндпоинт.
|
||||||
|
// ВНИМАНИЕ: tcpip рвёт adbd — вызывать только для простаивающего устройства.
|
||||||
|
// Возвращает "ip:port" при успехе, пустую строку при неудаче.
|
||||||
|
static QString tryBootstrap(const QString &usbSerial, const QString &androidId, int port);
|
||||||
|
|
||||||
|
// Удаляет сохранённый эндпоинт и делает adb disconnect (best-effort).
|
||||||
|
static void forget(const QString &androidId);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static void saveEndpoint(const QString &androidId, const QString &endpoint, const QString &mac);
|
||||||
|
|
||||||
|
// Поиск текущего IP устройства по известному MAC (после смены IP по DHCP).
|
||||||
|
// subnetHintIp — последний известный IP (для определения /24 при ping-скане).
|
||||||
|
static QString findIpByMac(const QString &mac, const QString &subnetHintIp, bool allowSweep);
|
||||||
|
static QString arpLookupIp(const QString &normalizedMac);
|
||||||
|
static void pingSweepSubnet(const QString &subnetHintIp);
|
||||||
|
|
||||||
|
static QString normalizeMac(const QString &mac);
|
||||||
|
static QString ipOf(const QString &endpoint);
|
||||||
|
static QString portOf(const QString &endpoint);
|
||||||
|
};
|
||||||
20
config.ini
20
config.ini
@ -44,6 +44,26 @@ remotePath=/sdcard/arc_shot.png
|
|||||||
previewIntervalSecs=4
|
previewIntervalSecs=4
|
||||||
previewIntervalHttpSecs=2
|
previewIntervalHttpSecs=2
|
||||||
|
|
||||||
|
[wifi]
|
||||||
|
# Автоматический ADB-over-Wi-Fi: USB — основной транспорт, Wi-Fi — горячий резерв.
|
||||||
|
# enabled=false полностью отключает поднятие/переподключение беспроводных эндпоинтов.
|
||||||
|
enabled=true
|
||||||
|
# TCP-порт adbd для tcpip-режима (стандартный 5555).
|
||||||
|
port=5555
|
||||||
|
# Как часто (сек) переподключать сохранённые Wi-Fi-эндпоинты для устройств не онлайн.
|
||||||
|
reconnectIntervalSecs=30
|
||||||
|
# Минимальный интервал (сек) между попытками bootstrap (tcpip) одного устройства —
|
||||||
|
# чтобы не дёргать tcpip на устройстве без Wi-Fi / где прошивка сбрасывает режим.
|
||||||
|
bootstrapRetryIntervalSecs=120
|
||||||
|
# Искать устройство по MAC в ARP-таблице, если connect по сохранённому IP не удался
|
||||||
|
# (адрес сменился по DHCP). MAC снимается при bootstrap.
|
||||||
|
macRediscoverEnabled=true
|
||||||
|
# Минимальный интервал (сек) между попытками поиска по MAC (операция дороже connect).
|
||||||
|
macRediscoverIntervalSecs=300
|
||||||
|
# Прогревать ARP-таблицу ping-сканом подсети /24, если устройства нет в ARP. Медленно
|
||||||
|
# и шумно — по умолчанию выключено; включать только в выделенной сети фермы.
|
||||||
|
subnetSweepEnabled=false
|
||||||
|
|
||||||
[black]
|
[black]
|
||||||
android_package_name=app.blackwallet.ar
|
android_package_name=app.blackwallet.ar
|
||||||
currency=ARS
|
currency=ARS
|
||||||
|
|||||||
@ -267,9 +267,14 @@ void DatabaseManager::initSchema() {
|
|||||||
ip TEXT,
|
ip TEXT,
|
||||||
port INTEGER,
|
port INTEGER,
|
||||||
code TEXT,
|
code TEXT,
|
||||||
|
mac TEXT,
|
||||||
updated_at DATETIME,
|
updated_at DATETIME,
|
||||||
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
|
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
|
||||||
))");
|
))");
|
||||||
|
// Дозаливка колонок для БД, созданных более ранней версией схемы (ошибка
|
||||||
|
// "duplicate column" игнорируется — колонка уже есть на новых установках).
|
||||||
|
q.exec("ALTER TABLE wifi_connections ADD COLUMN code TEXT");
|
||||||
|
q.exec("ALTER TABLE wifi_connections ADD COLUMN mac TEXT");
|
||||||
|
|
||||||
db.commit();
|
db.commit();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,27 +14,30 @@ WifiConnection parseRow(const QSqlQuery &query) {
|
|||||||
wc.ip = query.value("ip").toString();
|
wc.ip = query.value("ip").toString();
|
||||||
wc.port = query.value("port").toInt();
|
wc.port = query.value("port").toInt();
|
||||||
wc.code = query.value("code").toString();
|
wc.code = query.value("code").toString();
|
||||||
|
wc.mac = query.value("mac").toString();
|
||||||
wc.updatedAt = query.value("updated_at").toString();
|
wc.updatedAt = query.value("updated_at").toString();
|
||||||
return wc;
|
return wc;
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port,
|
bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port,
|
||||||
const QString &code) {
|
const QString &code, const QString &mac) {
|
||||||
QSqlQuery query(DatabaseManager::instance().database());
|
QSqlQuery query(DatabaseManager::instance().database());
|
||||||
query.prepare(R"(
|
query.prepare(R"(
|
||||||
INSERT INTO wifi_connections (android_id, ip, port, code, updated_at)
|
INSERT INTO wifi_connections (android_id, ip, port, code, mac, updated_at)
|
||||||
VALUES (:android_id, :ip, :port, :code, :updated_at)
|
VALUES (:android_id, :ip, :port, :code, :mac, :updated_at)
|
||||||
ON CONFLICT(android_id) DO UPDATE SET
|
ON CONFLICT(android_id) DO UPDATE SET
|
||||||
ip = excluded.ip,
|
ip = excluded.ip,
|
||||||
port = excluded.port,
|
port = excluded.port,
|
||||||
code = excluded.code,
|
code = excluded.code,
|
||||||
|
mac = excluded.mac,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
)");
|
)");
|
||||||
query.bindValue(":android_id", androidId);
|
query.bindValue(":android_id", androidId);
|
||||||
query.bindValue(":ip", ip);
|
query.bindValue(":ip", ip);
|
||||||
query.bindValue(":port", port);
|
query.bindValue(":port", port);
|
||||||
query.bindValue(":code", code);
|
query.bindValue(":code", code);
|
||||||
|
query.bindValue(":mac", mac);
|
||||||
query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||||
if (!query.exec()) {
|
if (!query.exec()) {
|
||||||
qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text();
|
qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text();
|
||||||
|
|||||||
@ -10,6 +10,7 @@ struct WifiConnection {
|
|||||||
QString ip; // последний известный IP телефона в Wi-Fi
|
QString ip; // последний известный IP телефона в Wi-Fi
|
||||||
int port = 0; // порт для adb connect (5555 для legacy tcpip)
|
int port = 0; // порт для adb connect (5555 для legacy tcpip)
|
||||||
QString code; // одноразовый pairing-код (пусто для legacy tcpip)
|
QString code; // одноразовый pairing-код (пусто для legacy tcpip)
|
||||||
|
QString mac; // нормализованный MAC wlan0 — для поиска нового IP по ARP при смене DHCP
|
||||||
QString updatedAt; // ISO-время последнего обновления
|
QString updatedAt; // ISO-время последнего обновления
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -20,7 +21,7 @@ class WifiConnectionDAO {
|
|||||||
public:
|
public:
|
||||||
// Вставка/обновление по android_id (PRIMARY KEY).
|
// Вставка/обновление по android_id (PRIMARY KEY).
|
||||||
static bool upsert(const QString &androidId, const QString &ip, int port,
|
static bool upsert(const QString &androidId, const QString &ip, int port,
|
||||||
const QString &code = {});
|
const QString &code = {}, const QString &mac = {});
|
||||||
|
|
||||||
// Запись по android_id (пустой androidId в результате → не найдено).
|
// Запись по android_id (пустой androidId в результате → не найдено).
|
||||||
[[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId);
|
[[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId);
|
||||||
|
|||||||
155
docs/wifi-adb.md
Normal file
155
docs/wifi-adb.md
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# ADB через Wi-Fi (TCP/IP)
|
||||||
|
|
||||||
|
ARC умеет работать с устройствами не только по USB-кабелю, но и по Wi-Fi
|
||||||
|
(ADB-over-TCP). Включается автоматически, **без участия пользователя**: USB остаётся
|
||||||
|
основным транспортом, Wi-Fi — горячий резерв, который подхватывается при отключении
|
||||||
|
кабеля.
|
||||||
|
|
||||||
|
## Поведение
|
||||||
|
|
||||||
|
1. Пользователь, как и раньше, подключает телефон по USB.
|
||||||
|
2. Фоновый `DeviceScreener`, обнаружив **простаивающее** USB-устройство без живого
|
||||||
|
Wi-Fi-эндпоинта, автоматически:
|
||||||
|
- читает IP телефона в сети Wi-Fi (`wlan0`) и его MAC;
|
||||||
|
- переводит adbd в TCP-режим (`adb tcpip <port>`);
|
||||||
|
- подключается по сети (`adb connect ip:port`);
|
||||||
|
- сохраняет эндпоинт в `settings` (ключ `wifi_endpoints`, JSON
|
||||||
|
`{ android_id: { "endpoint": "ip:port", "mac": "aa:bb:.." } }`; старый формат —
|
||||||
|
значение-строка `"ip:port"` — читается прозрачно).
|
||||||
|
3. После этого кабель можно отключить — устройство **остаётся онлайн** (статус
|
||||||
|
привязан к `android_id`, а не к ADB-serial; меняется только `adbSerial` на
|
||||||
|
`ip:port`).
|
||||||
|
4. При старте ARC и периодически воркер переподключает сохранённые эндпоинты
|
||||||
|
(`adb connect`), так что устройство, у которого кабель уже отключён, само
|
||||||
|
появляется онлайн.
|
||||||
|
5. Если беспроводной эндпоинт «завис», воркер чинит его пересозданием TCP
|
||||||
|
(`adb disconnect` → `adb connect`): голый повторный `connect` вернул бы
|
||||||
|
`already connected` и ничего бы не починил. `disconnect` ещё и расклинивает это
|
||||||
|
«залипшее» состояние — после него последующие `connect` (в т.ч. из периодического
|
||||||
|
reconnect) становятся реальными попытками. Срабатывает по двум сигналам:
|
||||||
|
- эндпоинт виден в `adb devices` со статусом `offline`;
|
||||||
|
- устройство ушло в debounced-offline (30 c не отвечает), хотя `adb devices` всё ещё
|
||||||
|
держит его как `device` — частый реальный случай «полумёртвого» соединения при
|
||||||
|
слабом Wi-Fi / уходе телефона в doze, когда команды виснут, а статус не меняется
|
||||||
|
(принудительный `disconnect→connect`, «kick», на переходе в debounced-offline).
|
||||||
|
Починка **пропускается** для устройств с активной/ожидающей задачей (см. инварианты).
|
||||||
|
6. Если `connect` по сохранённому IP не удался (адрес сменился по DHCP), воркер ищет
|
||||||
|
устройство в сети по сохранённому **MAC** (ARP-таблица ОС, при включённой опции —
|
||||||
|
с прогревом через ping-скан /24) и переподключается на новый IP, обновляя эндпоинт.
|
||||||
|
|
||||||
|
## Инварианты
|
||||||
|
|
||||||
|
- **USB приоритетнее.** Если один `android_id` виден одновременно по USB-serial и по
|
||||||
|
`ip:port`, каноническим `adbSerial` (по нему ходят банк-скрипты) выбирается USB.
|
||||||
|
Wi-Fi-эндпоинт остаётся резервом и используется только когда USB пропадёт.
|
||||||
|
- **Разрывающие операции — только на простаивающих устройствах.** `adb tcpip`
|
||||||
|
перезапускает adbd, а принудительный `disconnect→connect` (kick) рвёт текущее
|
||||||
|
соединение — поэтому ни bootstrap, ни kick не выполняются, если на устройстве есть
|
||||||
|
активная/ожидающая задача (тот же сигнал занятости
|
||||||
|
`EventDAO::getDevicesWithPendingEvents()`, по которому `EventHandler` не назначает
|
||||||
|
задачи). Иначе разрыв оборвал бы выполняющийся bank-скрипт.
|
||||||
|
- **Бесшовное отключение кабеля.** К моменту отключения USB Wi-Fi-эндпоинт уже поднят
|
||||||
|
и виден в `adb devices`, поэтому перехода в офлайн не происходит.
|
||||||
|
|
||||||
|
## Конфигурация (`config.ini`, секция `[wifi]`)
|
||||||
|
|
||||||
|
| Ключ | Назначение | Дефолт |
|
||||||
|
|---|---|---|
|
||||||
|
| `enabled` | Полностью вкл/выкл функционал | `true` |
|
||||||
|
| `port` | TCP-порт adbd для tcpip-режима | `5555` |
|
||||||
|
| `reconnectIntervalSecs` | Как часто переподключать сохранённые эндпоинты | `30` |
|
||||||
|
| `bootstrapRetryIntervalSecs` | Мин. интервал между попытками bootstrap одного устройства | `120` |
|
||||||
|
| `macRediscoverEnabled` | Искать устройство по MAC при смене IP (DHCP) | `true` |
|
||||||
|
| `macRediscoverIntervalSecs` | Мин. интервал между попытками поиска по MAC | `300` |
|
||||||
|
| `subnetSweepEnabled` | Прогрев ARP ping-сканом /24 (медленно/шумно) | `false` |
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- **Перезагрузка телефона** сбрасывает tcpip-режим (adbd возвращается в USB-only).
|
||||||
|
Wi-Fi-резерв поднимется заново при следующем подключении кабеля.
|
||||||
|
- **Смена IP** (новый адрес по DHCP) обрабатывается поиском по MAC: при неуспешном
|
||||||
|
`adb connect` воркер находит устройство в ARP-таблице по сохранённому MAC и
|
||||||
|
переподключается на новый IP. Если устройства нет в ARP, помогает либо включённый
|
||||||
|
`subnetSweepEnabled` (ping-скан /24 — медленно, шумно), либо повторный bootstrap по
|
||||||
|
USB. Поиск по MAC требует, чтобы ARC и устройство были в одной L2-подсети (ARP не
|
||||||
|
маршрутизируется), и работает только когда MAC был снят при bootstrap.
|
||||||
|
- Требуется, чтобы телефон и десктоп были в одной сети Wi-Fi (маршрутизируемой до
|
||||||
|
`ip:port` устройства).
|
||||||
|
- **Чтение MAC** идёт через `ip addr show wlan0` (та же команда, что и для IP), фолбэк —
|
||||||
|
`cmd wifi status`. Оба проходят без root, включая MIUI/HyperOS (где `cat /sys/...` и
|
||||||
|
`ip link show` отдают Permission denied). Обычно это рандомизированный per-SSID MAC, но
|
||||||
|
он стабилен для сети и совпадает с тем, что видит ARP-таблица ПК. Если MAC снять не
|
||||||
|
удалось — эндпоинт хранится без него: теряется только восстановление при смене IP,
|
||||||
|
остальное (bootstrap, reconnect, kick) работает.
|
||||||
|
- **Скриншоты по Wi-Fi медленнее.** Основной путь захвата — http через APK arc-shot (байты
|
||||||
|
идут мимо adb, см. `[screenshot]` в config.ini); при его недоступности — fallback на
|
||||||
|
`exec-out screencap`, который отдаёт полноразмерный PNG через adb и по сети тяжелее USB.
|
||||||
|
Для wireless-serial таймаут fallback-захвата поднят (12 c против 10 c по USB). Интервал
|
||||||
|
превью задаётся `[screenshot]` (`previewIntervalHttpSecs` для http, `previewIntervalSecs`
|
||||||
|
для exec-out) и от транспорта Wi-Fi не зависит напрямую. На детект online/offline не
|
||||||
|
влияет; на слабом линке отдельные кадры всё равно могут не успевать.
|
||||||
|
- **Залипшее соединение vs. статус `offline`.** На практике мёртвый Wi-Fi часто остаётся
|
||||||
|
в `adb devices` как `device` (команды виснут), а не переходит в `offline` — поэтому
|
||||||
|
основной триггер починки именно debounced-offline, а не статус `offline` (см. Поведение).
|
||||||
|
|
||||||
|
## Требования к сети
|
||||||
|
|
||||||
|
Не требуют доработок ARC — это настройки сети/развёртывания.
|
||||||
|
|
||||||
|
**Обязательно (иначе Wi-Fi-ADB не работает):**
|
||||||
|
- ПК с ARC и устройства — в одной L2-подсети (или маршрутизируемой до `ip:5555`).
|
||||||
|
- На роутере **отключить AP isolation (изоляцию клиентов Wi-Fi)** — иначе порт `5555`
|
||||||
|
между узлами недоступен.
|
||||||
|
- Единый `adb` (бандл `tools/adb/...`), один adb-сервер — чтобы списки устройств у ARC и
|
||||||
|
фоновых операций не расходились.
|
||||||
|
|
||||||
|
**Опционально (предсказуемый адрес устройства — снижает число переподключений):**
|
||||||
|
смену IP по DHCP ARC и так переживает поиском по MAC (см. Ограничения), поэтому ниже —
|
||||||
|
не требования, а способы сделать адрес стабильным:
|
||||||
|
|
||||||
|
| Способ | Где настраивать | Работает на Android <11 |
|
||||||
|
|---|---|---|
|
||||||
|
| DHCP-резервация по MAC | роутер | да |
|
||||||
|
| Статический IP на самом устройстве | каждое устройство | да |
|
||||||
|
| mDNS-имя (`adb connect <имя>.local`) | устройство + сеть | нет (только 11+; **в ARC не используется**) |
|
||||||
|
| Поиск по MAC силами ARC (`macRediscoverEnabled`) | — (уже в ARC) | да |
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
`adb tcpip` открывает порт **5555 на самом телефоне** (на его Wi-Fi-IP), а не на ПК.
|
||||||
|
Парк из N устройств = N эндпоинтов `ip:5555`, различаются по IP. Это отладочный доступ к
|
||||||
|
устройству, достижимый для **любого узла той же L2-подсети**. Доступ всё ещё проходит
|
||||||
|
RSA-авторизацию adb (чужой ключ упрётся в запрос «Allow debugging?» на экране), но сама
|
||||||
|
открытая поверхность + контекст (это **банковские приложения**) требуют сетевой гигиены.
|
||||||
|
На уровне приложения порт закрыть нельзя — на нерутованном устройстве это свойство adbd,
|
||||||
|
поэтому безопасность здесь **почти целиком инфраструктурная**:
|
||||||
|
|
||||||
|
- **Выделенная/изолированная Wi-Fi-сеть для фермы** — без выхода в недоверенный сегмент.
|
||||||
|
На роутере **отключить AP isolation** (иначе узлы не видят порт 5555 друг друга — это и
|
||||||
|
про работоспособность), но саму сеть держать сегментированной (VLAN/гостевую-наоборот).
|
||||||
|
- **Защита `adbkey` управляющего ПК.** RSA-ключ этой машины — фактический «пароль» ко
|
||||||
|
**всему** парку (особенно при «всегда разрешать с этого компьютера»). Хранить только на
|
||||||
|
ней, доступ к машине ограничить.
|
||||||
|
- **Ограничение на уровне роутера/файрвола** — сузить, кто вообще достаёт до `:5555`
|
||||||
|
устройств (по возможности — только управляющий ПК).
|
||||||
|
- **Поиск по MAC / `subnetSweepEnabled`** сканируют подсеть — ещё одна причина держать
|
||||||
|
ферму в выделённой сети (в чужой/общей сети скан нежелателен).
|
||||||
|
|
||||||
|
> Главный практический долг по безопасности — организационный: выделенная сеть, AP
|
||||||
|
> isolation off + сегментация, защита `adbkey`. В коде ARC тут рычагов нет.
|
||||||
|
|
||||||
|
## Где в коде
|
||||||
|
|
||||||
|
- `android/adb/AdbUtils` — низкоуровневые обёртки (`isWirelessSerial`,
|
||||||
|
`getDeviceWifiIp`, `getDeviceMac`, `enableTcpip`, `connectWireless`,
|
||||||
|
`disconnectWireless`).
|
||||||
|
- `android/adb/WifiAdbManager` — авто-bootstrap (`tryBootstrap`), переподключение
|
||||||
|
(`reconnectKnown`: починка `offline`-эндпоинтов через disconnect→connect и поиск по MAC
|
||||||
|
при смене IP), принудительный kick залипшего эндпоинта (`kickEndpoint`), хранение
|
||||||
|
эндпоинтов (`loadEndpoints`/`forget`).
|
||||||
|
- `services/DeviceScreener` — интеграция в цикл опроса (reconnect c починкой stale →
|
||||||
|
dedup USB/Wi-Fi → kick на debounced-offline (с guard по занятости) → bootstrap
|
||||||
|
простаивающих; увеличенный таймаут/интервал screencap для wireless).
|
||||||
|
- `views/device/DeviceWidget` — пассивный бейдж «Wi-Fi» на карточке устройства;
|
||||||
|
показывается **только когда устройство онлайн** по Wi-Fi (у офлайн-карточки не горит,
|
||||||
|
чтобы не вводить оператора в заблуждение).
|
||||||
BIN
images/wifi.png
Normal file
BIN
images/wifi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@ -19,11 +19,12 @@
|
|||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
#include <dao/DeviceDAO.h>
|
#include <dao/DeviceDAO.h>
|
||||||
#include <dao/WifiConnectionDAO.h>
|
|
||||||
#include <db/DeviceInfo.h>
|
#include <db/DeviceInfo.h>
|
||||||
|
|
||||||
#include "adb/AdbUtils.h"
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "adb/WifiAdbManager.h"
|
||||||
#include "dao/BankProfileDAO.h"
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/EventDAO.h"
|
||||||
#include "dao/MaterialDAO.h"
|
#include "dao/MaterialDAO.h"
|
||||||
#include "dao/SettingsDAO.h"
|
#include "dao/SettingsDAO.h"
|
||||||
#include "net/NetworkErrorUtils.h"
|
#include "net/NetworkErrorUtils.h"
|
||||||
@ -34,6 +35,18 @@ DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) {
|
|||||||
m_urlAppUpdate = settings.value("net/appInfoUpdate").toString();
|
m_urlAppUpdate = settings.value("net/appInfoUpdate").toString();
|
||||||
m_token = settings.value("common/token").toString();
|
m_token = settings.value("common/token").toString();
|
||||||
m_apiBase = settings.value("net/apiBase").toString();
|
m_apiBase = settings.value("net/apiBase").toString();
|
||||||
|
|
||||||
|
// ADB через Wi-Fi: все параметры из [wifi] config.ini (дефолты в .h — фолбэк).
|
||||||
|
m_wifiEnabled = settings.value("wifi/enabled", m_wifiEnabled).toBool();
|
||||||
|
m_wifiPort = settings.value("wifi/port", m_wifiPort).toInt();
|
||||||
|
m_wifiReconnectIntervalSecs =
|
||||||
|
settings.value("wifi/reconnectIntervalSecs", static_cast<qlonglong>(m_wifiReconnectIntervalSecs)).toLongLong();
|
||||||
|
m_wifiBootstrapRetrySecs =
|
||||||
|
settings.value("wifi/bootstrapRetryIntervalSecs", static_cast<qlonglong>(m_wifiBootstrapRetrySecs)).toLongLong();
|
||||||
|
m_wifiMacRediscover = settings.value("wifi/macRediscoverEnabled", m_wifiMacRediscover).toBool();
|
||||||
|
m_wifiMacRediscoverIntervalSecs =
|
||||||
|
settings.value("wifi/macRediscoverIntervalSecs", static_cast<qlonglong>(m_wifiMacRediscoverIntervalSecs)).toLongLong();
|
||||||
|
m_wifiSubnetSweep = settings.value("wifi/subnetSweepEnabled", m_wifiSubnetSweep).toBool();
|
||||||
}
|
}
|
||||||
|
|
||||||
DeviceScreener::~DeviceScreener() = default;
|
DeviceScreener::~DeviceScreener() = default;
|
||||||
@ -344,6 +357,34 @@ void DeviceScreener::start() {
|
|||||||
shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt();
|
shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt();
|
||||||
|
|
||||||
while (m_running) {
|
while (m_running) {
|
||||||
|
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
|
||||||
|
|
||||||
|
// Wi-Fi reconnect: до readAdbDevices, чтобы переподключённые по сети
|
||||||
|
// устройства попали в `adb devices` уже в этом цикле. Троттлим по конфигу.
|
||||||
|
// Пропускаем уже онлайн (предыдущий debounced-набор) — не дёргаем зря adb.
|
||||||
|
if (m_wifiEnabled && nowSec - m_lastWifiReconnect >= m_wifiReconnectIntervalSecs) {
|
||||||
|
m_lastWifiReconnect = nowSec;
|
||||||
|
// Протухшие offline Wi-Fi-эндпоинты в текущем adb devices: их reconnect чинит
|
||||||
|
// через disconnect→connect (голый connect вернул бы "already connected").
|
||||||
|
// Лишний adb devices — только когда сработал троттл reconnect (раз в N сек).
|
||||||
|
QSet<QString> staleWireless;
|
||||||
|
for (const auto &[serial, status] : readAdbDevices()) {
|
||||||
|
if (status == "offline" && AdbUtils::isWirelessSerial(serial)) {
|
||||||
|
staleWireless.insert(serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WifiAdbManager::ReconnectOptions opts;
|
||||||
|
opts.alreadyOnline = m_onlineDevices;
|
||||||
|
opts.staleEndpoints = staleWireless;
|
||||||
|
opts.subnetSweep = m_wifiSubnetSweep;
|
||||||
|
opts.macRediscover = m_wifiMacRediscover
|
||||||
|
&& nowSec - m_lastMacRediscover >= m_wifiMacRediscoverIntervalSecs;
|
||||||
|
if (opts.macRediscover) {
|
||||||
|
m_lastMacRediscover = nowSec;
|
||||||
|
}
|
||||||
|
WifiAdbManager::reconnectKnown(opts);
|
||||||
|
}
|
||||||
|
|
||||||
bool adbOk = true;
|
bool adbOk = true;
|
||||||
QList<QPair<QString, QString> > devices = readAdbDevices(&adbOk);
|
QList<QPair<QString, QString> > devices = readAdbDevices(&adbOk);
|
||||||
// Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит,
|
// Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит,
|
||||||
@ -355,7 +396,6 @@ void DeviceScreener::start() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
|
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
|
||||||
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
|
|
||||||
|
|
||||||
// --- Фаза A: чистый adb-I/O параллельно по всем "device".
|
// --- Фаза A: чистый adb-I/O параллельно по всем "device".
|
||||||
// Устройства не в статусе "device" (offline/unauthorized) игнорируем — они
|
// Устройства не в статусе "device" (offline/unauthorized) игнорируем — они
|
||||||
@ -393,11 +433,37 @@ void DeviceScreener::start() {
|
|||||||
appsDue.contains(serial), shotDue.contains(serial));
|
appsDue.contains(serial), shotDue.contains(serial));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// USB приоритетнее Wi-Fi: один и тот же android_id может быть виден сразу
|
||||||
|
// по USB-serial и по Wi-Fi-эндпоинту (ip:port). Канонический adbSerial для
|
||||||
|
// устройства выбираем USB (Wi-Fi — горячий резерв), чтобы скрипты ходили по
|
||||||
|
// кабелю, а в БД adbSerial не «прыгал» между транспортами. При отключении
|
||||||
|
// кабеля останется только Wi-Fi-serial → он и станет каноническим, без
|
||||||
|
// перехода устройства в офлайн (liveSet/онлайн привязаны к android_id).
|
||||||
|
QMap<QString, QString> preferredSerial; // android_id → выбранный adbSerial
|
||||||
|
for (const ScanResult &scan : scans) {
|
||||||
|
if (!scan.identified) continue;
|
||||||
|
const QString &id = scan.info.id;
|
||||||
|
if (!preferredSerial.contains(id)) {
|
||||||
|
preferredSerial[id] = scan.adbSerial;
|
||||||
|
} else if (AdbUtils::isWirelessSerial(preferredSerial[id])
|
||||||
|
&& !AdbUtils::isWirelessSerial(scan.adbSerial)) {
|
||||||
|
preferredSerial[id] = scan.adbSerial; // заменяем Wi-Fi на USB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Фаза B: всё, что трогает БД/члены класса — последовательно в этом потоке.
|
// --- Фаза B: всё, что трогает БД/члены класса — последовательно в этом потоке.
|
||||||
for (const ScanResult &scan : scans) {
|
for (const ScanResult &scan : scans) {
|
||||||
// android_id не прочитался — пропускаем устройство в этом цикле.
|
// android_id не прочитался — пропускаем устройство в этом цикле.
|
||||||
if (!scan.identified) continue;
|
if (!scan.identified) continue;
|
||||||
|
|
||||||
|
// Не-победитель дубля (тот же id виден по другому транспорту): устройство
|
||||||
|
// всё равно онлайн — отмечаем в liveSet (для дебаунса), но БД/превью/API
|
||||||
|
// обновляет только победитель, иначе adbSerial будет осциллировать.
|
||||||
|
if (preferredSerial.value(scan.info.id) != scan.adbSerial) {
|
||||||
|
liveSet.insert(scan.info.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
DeviceInfo device = scan.info;
|
DeviceInfo device = scan.info;
|
||||||
device.status = "device";
|
device.status = "device";
|
||||||
device.updateTime = QDateTime::currentDateTime();
|
device.updateTime = QDateTime::currentDateTime();
|
||||||
@ -447,18 +513,6 @@ void DeviceScreener::start() {
|
|||||||
qDebug() << "Cant update device";
|
qDebug() << "Cant update device";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем Wi-Fi IP телефона в wifi_connections (по android_id) — задел под
|
|
||||||
// авто-реконнект. Резолвим напрямую (независимо от http-скриншота, чтобы IP
|
|
||||||
// был и для exec-out-устройств); resolvePhoneIp кэширует с TTL ~5 мин, так что
|
|
||||||
// adb дёргается редко. Пишем только при изменении. upsert после upsertDevice —
|
|
||||||
// FK wifi_connections → devices(id).
|
|
||||||
if (const QString ip = AdbUtils::resolvePhoneIp(scan.adbSerial);
|
|
||||||
!ip.isEmpty() && m_lastPersistedIp.value(device.id) != ip) {
|
|
||||||
if (WifiConnectionDAO::upsert(device.id, ip, 5555)) {
|
|
||||||
m_lastPersistedIp[device.id] = ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем установленные приложения по троттлу (список прочитан в фазе A).
|
// Проверяем установленные приложения по троттлу (список прочитан в фазе A).
|
||||||
// Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след.
|
// Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след.
|
||||||
// цикле и не пометить все приложения как отсутствующие.
|
// цикле и не пометить все приложения как отсутствующие.
|
||||||
@ -551,6 +605,12 @@ void DeviceScreener::start() {
|
|||||||
goneOffline.insert(id);
|
goneOffline.insert(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Устройства с активной/ожидающей задачей не «кикаем»: disconnect порвал бы
|
||||||
|
// выполняющийся bank-скрипт. Тот же сигнал занятости, что и для bootstrap.
|
||||||
|
// Считаем один раз и только если есть кого переводить в офлайн.
|
||||||
|
const QSet<QString> busyForKick =
|
||||||
|
(m_wifiEnabled && !goneOffline.isEmpty()) ? EventDAO::getDevicesWithPendingEvents()
|
||||||
|
: QSet<QString>();
|
||||||
for (const QString &id : goneOffline) {
|
for (const QString &id : goneOffline) {
|
||||||
m_onlineDevices.remove(id);
|
m_onlineDevices.remove(id);
|
||||||
m_offlineSince.remove(id);
|
m_offlineSince.remove(id);
|
||||||
@ -572,6 +632,15 @@ void DeviceScreener::start() {
|
|||||||
updateDeviceOnApi(offlineCopy);
|
updateDeviceOnApi(offlineCopy);
|
||||||
}
|
}
|
||||||
emit deviceWentOffline(id);
|
emit deviceWentOffline(id);
|
||||||
|
|
||||||
|
// Wi-Fi-эндпоинт мог «залипнуть» в полумёртвом состоянии: adb держит его как
|
||||||
|
// "device", но команды виснут — поэтому устройство ушло в debounced-offline,
|
||||||
|
// а статус "offline" в `adb devices` так и не появился (наш staleEndpoints его
|
||||||
|
// не ловит). На переходе в офлайн форсим пересоздание TCP (disconnect→connect).
|
||||||
|
// Для офлайна по USB (нет сохранённого Wi-Fi-эндпоинта) это no-op.
|
||||||
|
if (m_wifiEnabled && !busyForKick.contains(id)) {
|
||||||
|
WifiAdbManager::kickEndpoint(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// БД status: считаем онлайном только debounced-онлайн.
|
// БД status: считаем онлайном только debounced-онлайн.
|
||||||
@ -586,6 +655,35 @@ void DeviceScreener::start() {
|
|||||||
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
|
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
|
||||||
emit firstPassDone();
|
emit firstPassDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Wi-Fi bootstrap: для простаивающих USB-устройств без живого Wi-Fi-резерва
|
||||||
|
// автоматически поднимаем беспроводной эндпоинт (tcpip → ip → connect).
|
||||||
|
// tcpip перезапускает adbd и рвёт соединение, поэтому делаем это ТОЛЬКО на
|
||||||
|
// устройствах без активной/ожидающей задачи (тот же сигнал занятости, по
|
||||||
|
// которому EventHandler не шлёт задачи). В самом конце цикла, чтобы
|
||||||
|
// блокирующий tcpip+connect не задерживал детект online/offline.
|
||||||
|
if (m_wifiEnabled) {
|
||||||
|
QSet<QString> wifiLiveIds; // android_id уже с живым Wi-Fi-эндпоинтом
|
||||||
|
QMap<QString, QString> usbSerialById; // android_id → USB-serial (кандидаты)
|
||||||
|
for (const ScanResult &scan : scans) {
|
||||||
|
if (!scan.identified) continue;
|
||||||
|
if (AdbUtils::isWirelessSerial(scan.adbSerial)) {
|
||||||
|
wifiLiveIds.insert(scan.info.id);
|
||||||
|
} else {
|
||||||
|
usbSerialById[scan.info.id] = scan.adbSerial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const QSet<QString> busyDevices = EventDAO::getDevicesWithPendingEvents();
|
||||||
|
for (auto it = usbSerialById.constBegin(); it != usbSerialById.constEnd(); ++it) {
|
||||||
|
const QString &id = it.key();
|
||||||
|
if (wifiLiveIds.contains(id)) continue; // Wi-Fi-резерв уже поднят
|
||||||
|
if (busyDevices.contains(id)) continue; // занято задачей — tcpip рвал бы её
|
||||||
|
if (nowSec - m_lastWifiBootstrap.value(id, 0) < m_wifiBootstrapRetrySecs) continue;
|
||||||
|
m_lastWifiBootstrap[id] = nowSec;
|
||||||
|
WifiAdbManager::tryBootstrap(it.value(), id, m_wifiPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QThread::msleep(1000);
|
QThread::msleep(1000);
|
||||||
}
|
}
|
||||||
emit finished();
|
emit finished();
|
||||||
|
|||||||
@ -65,7 +65,18 @@ private:
|
|||||||
QMap<QString, DeviceInfo> m_staticInfoCache;
|
QMap<QString, DeviceInfo> m_staticInfoCache;
|
||||||
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
|
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
|
||||||
QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью)
|
QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью)
|
||||||
QMap<QString, QString> m_lastPersistedIp; // android_id → последний записанный в wifi_connections IP (анти-дубль)
|
|
||||||
|
// --- ADB через Wi-Fi (USB — основной транспорт, Wi-Fi — горячий резерв) ---
|
||||||
|
bool m_wifiEnabled = true; // [wifi] enabled
|
||||||
|
int m_wifiPort = 5555; // [wifi] port (tcpip)
|
||||||
|
qint64 m_wifiReconnectIntervalSecs = 30; // [wifi] reconnectIntervalSecs
|
||||||
|
qint64 m_wifiBootstrapRetrySecs = 120; // [wifi] bootstrapRetryIntervalSecs
|
||||||
|
qint64 m_lastWifiReconnect = 0; // unix ts последнего reconnectKnown
|
||||||
|
QMap<QString, qint64> m_lastWifiBootstrap; // android_id → unix ts последней попытки bootstrap
|
||||||
|
bool m_wifiMacRediscover = true; // [wifi] macRediscoverEnabled (искать новый IP по MAC)
|
||||||
|
qint64 m_wifiMacRediscoverIntervalSecs = 300; // [wifi] macRediscoverIntervalSecs (троттл поиска по MAC)
|
||||||
|
bool m_wifiSubnetSweep = false; // [wifi] subnetSweepEnabled (ping-скан подсети для ARP)
|
||||||
|
qint64 m_lastMacRediscover = 0; // unix ts последнего поиска по MAC
|
||||||
|
|
||||||
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
|
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
|
||||||
// потоков). Не содержит ничего, что требует БД/членов класса.
|
// потоков). Не содержит ничего, что требует БД/членов класса.
|
||||||
|
|||||||
@ -656,6 +656,29 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
|
|||||||
topRightLayout->addWidget(settingsBtn);
|
topRightLayout->addWidget(settingsBtn);
|
||||||
// topRightLayout->addWidget(deleteBtn);
|
// topRightLayout->addWidget(deleteBtn);
|
||||||
topRightLayout->addWidget(codeBtn);
|
topRightLayout->addWidget(codeBtn);
|
||||||
|
|
||||||
|
// Пассивный мини-индикатор Wi-Fi: устройство работает по ADB-over-TCP (кабель не
|
||||||
|
// нужен). Только информация для оператора, без интерактива. Определяем по виду
|
||||||
|
// adbSerial (ip:port). USB-устройства бейдж не показывают. Оформлен в одном стиле
|
||||||
|
// с кнопками выше (шестерёнка/код) и стоит под ними, чтобы не перекрывать id.
|
||||||
|
// Показываем ТОЛЬКО когда устройство онлайн: у офлайн-карточки горящий бейдж
|
||||||
|
// означал бы активное Wi-Fi-соединение, которого нет — это путало оператора.
|
||||||
|
if (device.status == "device" && AdbUtils::isWirelessSerial(m_device.adbSerial)) {
|
||||||
|
auto *wifiBadge = new QLabel(this);
|
||||||
|
wifiBadge->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
wifiBadge->setToolTip(tr("Подключено по Wi-Fi"));
|
||||||
|
wifiBadge->setFixedSize(28, 28);
|
||||||
|
wifiBadge->setAlignment(Qt::AlignCenter);
|
||||||
|
const QPixmap wifiPix(QCoreApplication::applicationDirPath() + "/images/wifi.png");
|
||||||
|
wifiBadge->setPixmap(wifiPix.scaled(20, 20, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
||||||
|
wifiBadge->setStyleSheet(
|
||||||
|
"background-color: rgba(0, 0, 0, 0.5);"
|
||||||
|
"border: none;"
|
||||||
|
"border-radius: 4px;"
|
||||||
|
);
|
||||||
|
topRightLayout->addWidget(wifiBadge);
|
||||||
|
}
|
||||||
|
|
||||||
layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight);
|
layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight);
|
||||||
|
|
||||||
if (device.status != "device") {
|
if (device.status != "device") {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user