Низкоуровневое обнаружение Wi-Fi устройств в домашней сети
Чтобы сделать собственное уникальное устройство для «умного дома» сейчас достаточно купить микроконтроллер и электронные компоненты. Конечно, на рынке уже есть множество «умных» устройств, но не все производители предоставляют открытое API, и уж точно единицы разрешают (или по крайней мере не запрещают) создавать собственные прошивки. Иногда наступает тот момент, когда кажется, что разработать и запрограммировать собственное устройство будет лучшим решением.
В этой статье я расскажу про несколько способов »научить» микроконтроллер распознавать присутствие людей дома исключительно с помощью Wi-Fi.
Предисловие
Источник изображения
Мне с детства нравились часы. Конечно, желания заполнить всю комнату звенящими часами, словно Доктор Браун, у меня не было, но часов в моей комнате было достаточно. Тем не менее, пришлось повзрослеть и желание иметь множество часов как-то приутихло.
Наступила взрослая самостоятельная жизнь и вопрос с часами стал одним из маленьких конфликтов интересов. Мне хотелось иметь светящиеся часы, которые ненавязчиво покажут время в любое время дня и ночи. Моя девушка же придерживается мнения, что никакой свет, даже слабый, не должен препятствовать засыпанию.
Компромисс находился простой: в процессе подготовки ко сну выключать часы. Правда, их нужно с утра как-то включить, что в свою очередь нерационально: проще взять телефон, там время тоже есть. Значит, нужно как-то автоматически определять моменты, подходящие для отключения и включения часов.
Быстро проанализировав подготовку ко сну, я обнаружил там повторяющееся действие, а именно, отключение Wi-Fi на телефоне. Такой триггер позволит «умным» часам выключаться не по сухому «расписанию», а в нужные моменты времени.
Давайте определимся, из чего состоят часы и какие ограничения накладываются на окружение.
Что там внутри
Подключение платы к светодиодной матрице (источник alexgyver.ru)
Набор юного «самодельщика» прост:
- плата Wemos D1 Mini на базе чипа ESP8266 с Wi-Fi;
- светодиодная WS2812B-совместимая матрица размером 32×8;
- блок питания 5В, 2А;
- для разработки прошивки используется Arduino IDE.
Разумеется, полагаться на внутренние часы микроконтроллера неразумно и нужна отдельная плата часов реального времени. Однако, согласно любительскому исследованию, скорость расхождения внутреннего времени микроконтроллера составляет примерно одну секунду в день. Это не критично для настенных часов, а при наличии доступа в интернет, синхронизация с сервером времени решит проблему.
Минимальные вложения для сборки данного устройства накладывают следующие ограничения:
- в домашней сети отсутствуют какие-либо системы, выполняющие мониторинг сети;
- допустимы изменения в конфигурации домашнего роутера;
- допустимы изменения в конфигурации сетевого подключения на телефоне;
- модификация ПО домашнего роутера запрещена;
- модификация ПО телефона запрещена.
Под модификацией ПО подразумевается создание собственных программных решений, которые по событиям в операционной системе телефона могут отправить команду. Во-первых это »не спортивно», а во-вторых потребует кроссплатформенной разработки, если захочется включить в поддержку чей-то iPhone, а потом и пару ноутбуков с разными операционными системами.
Современные устройства умеют подменять MAC-адрес во имя конфиденциальности
Единственный уникальный идентификатор, с помощью которого можно найти телефон в домашней сети — MAC-адрес. Однако, современная техника умеет генерировать «подставной» MAC-адрес, что усложняет определение устройства. Тем не менее, для заданных Wi-Fi-сетей эту опцию можно отключить.
Итак, у нас есть MAC-адрес, что будем делать?
Поиск устройства
Можно придумать несколько вариантов в зависимости от искомого устройства и используемого маршрутизатора/точки доступа.
Большую часть решений объединяет одно: подключение к домашнему Wi-Fi. Минимальный код, с которым будем работать.
#include
#include
void setup() {
Serial.begin(115200);
WiFiManager wifiManager;
wifiManager.setDebugOutput(false);
wifiManager.autoConnect("habr-example", "supergeneral");
Serial.print("Connected! IP address: ");
Serial.println(WiFi.localIP());
/* Здесь также инициализация для FastLED и других библиотек,
* которые не важны для данного примера
**/
}
void loop() {
// Основной код
ledTick();
}
Для упрощения работы с Wi-Fi используется библиотека WiFiManager. Если в памяти микроконтроллера нет информации о известных точках или они недоступны, WiFiManager запустит собственную точку доступа с веб-интерфейсом для быстрого подключения к новому Wi-Fi.
Кто там
Самое простое решение всегда на поверхности: давайте «пинганем» телефон. На поверку, в мире микроконтроллеров протокол ICMP используется неохотно. Так, в lwIP (lightweight IP, реализации стека TCP/IP для встраиваемых систем) есть минимальная поддержка протокола ICMP, но этого недостаточно. Для наших целей придется поставить библиотеку ESP8266-ping.
Для уведомления об успешном или неуспешном пинге библиотека использует функции обратного вызова. Напишем функцию с простой логикой:
- если устройство было недоступно, а сейчас доступно — устройство появилось в сети;
- если устройство недоступно MAX_PING попыток подряд — устройство ушло из сети.
#define MAX_PING 5
boolean current_state = false;
unsigned char attempts = 0;
void* responseCallback(const PingerResponse& response) {
if(response.ReceivedResponse) {
if(current_state == false) {
attempts = 0;
current_state = true;
Serial.println("Device on");
}
} else {
if(current_state == true) {
attempts++;
if(attempts > MAX_PING) {
current_state = false;
Serial.println("Device off");
}
}
}
return (void*)true;
}
Инициализируем библиотеку ESP8266-ping:
#define PING_INTERVAL 1000
Pinger pinger;
void setup() {
// Общая инициализация опущена
pinger.OnReceive(&responseCallback);
}
Так как пинг — не единственная наша задача, создаем функцию, которая раз в PING_INTERVAL миллисекунд отправляет ICMP-пакет.
unsigned long previousTime = 0;
void pingTick() {
if(millis() - previousTime > PING_INTERVAL) {
previousTime = millis();
pinger.Ping("192.168.88.148", 1, PING_INTERVAL / 2);
}
}
void loop() {
// Другие Tick() функции опущены
pingTick();
}
#include
#include
#include
#define MAX_PING 5
#define PING_INTERVAL 1000
Pinger pinger;
boolean current_state = false;
unsigned char attempts = 0;
void* responseCallback(const PingerResponse& response) {
if(response.ReceivedResponse) {
if(current_state == false) {
attempts = 0;
current_state = true;
Serial.println("Device on");
}
} else {
if(current_state == true) {
attempts++;
if(attempts > MAX_PING) {
current_state = false;
Serial.println("Device off");
}
}
}
return (void*)true;
}
unsigned long previousTime = 0;
void pingTick() {
if(millis() - previousTime > PING_INTERVAL) {
previousTime = millis();
pinger.Ping("192.168.88.148", 1, 1000);
}
}
void setup() {
Serial.begin(115200);
WiFiManager wifiManager;
wifiManager.setDebugOutput(false);
wifiManager.autoConnect("habr-example", "supergeneral");
Serial.print("Connected! IP address: ");
Serial.println(WiFi.localIP());
pinger.OnReceive(&responseCallback);
}
void loop() {
pingTick();
}
Третий аргумент функции Ping задает время ожидания ответа и ему стоит быть меньше, чем промежутки между пингами. Однако, здесь фигурирует только IP-адрес, еще и явно прописанный в прошивке. Есть два решения данной ситуации:
- в настройках DHCP-сервера явно «прибить» адрес к MAC-адресу искомого устройства;
- пинговать все адреса подсети и проверять MAC-адрес.
При условии, что часы — это домашнее устройство, а домашнюю сеть и телефоны не меняют пять раз на дню, то первое решение выглядит достойно. При этом время реакции часов на выход устройства из сети — MAX_PING * PING_INTERVAL миллисекунд.
Но случаются вредные устройства, которые не отвечают на ICMP-запросы.
Открывайте! Мы знаем, что вы тут
Далеко за примером ходить не надо: операционная система Microsoft Windows по умолчанию игнорирует ICMP-запросы. Такой расклад дел не сильно усложняет жизнь. Устройство может игнорировать ICMP-запросы, но ARP-запросы ему проигнорировать не получится. Поэтому для «особо вредных» устройств у нас более хитрый план: очищаем ARP-таблицу, отправляем несколько пингов, проверяем ARP-таблицу.
ARP (англ. Address Resolution Protocol — протокол определения адреса) — протокол в компьютерных сетях, предназначенный для определения MAC-адреса по IP-адресу другого компьютера. © Википедия
Особенность ARP-протокола заключается в том, что он работает только в пределах одного Ethernet-сегмента. Тем не менее, ожидается, что домашняя сеть не должна быть сложной.
Доступ к ARP-таблицам на ESP8266 возможен через функции lwIP. Эти функции — для смелых и простым смертным не нужны, поэтому примеров и объяснений достаточно мало, нужно читать еще и комментарии к коду. Добавляем в проект включение заголовочных файлов lwip:
#include
Удаляем функцию обратного вызова и изменяем pingTick () следующим образом:
void pingTick() {
if(millis() - previousTime > PING_INTERVAL) {
previousTime = millis();
// IP-адрес искомого устройства, может быть глобальным
IPAddress addr = IPAddress(192,168,88,148);
// Итерация по ARP-таблице
ip4_addr_t *ip;
struct netif *netif;
struct eth_addr *ethaddr;
bool found = false;
for(int i=0; iaddr & 0xFF) &&
addr[1] == (ip->addr >> 8 & 0xFF) &&
addr[2] == (ip->addr >> 16 & 0xFF) &&
addr[3] == (ip->addr >> 24 & 0xFF)) {
found = true;
}
}
}
// Очищаем ARP-таблицу
etharp_cleanup_netif(netif);
// Запускаем следующий раунд пингов
pinger.Ping(addr, 5, 100);
// Обрабатываем информацию
if(found) {
Serial.println("Device on");
} else {
Serial.println("Device off");
}
}
}
Время реакции этого способа равно PING_INTERVAL, в моем случае я увеличил это число до пяти секунд. Способ потенциально хороший, но в тестах в моей домашней сети он постоянно сбоил и способ с ICMP-ответами работал стабильнее. Поэтому если ваше устройство не скупится отвечать на пинг, то лучше использовать предыдущий способ.
#include
#include
#include
#include
#define MAX_PING 5
#define PING_INTERVAL 5000
Pinger pinger;
boolean current_state = false;
unsigned char attempts = 0;
unsigned long previousTime = 0;
void pingTick() {
if(millis() - previousTime > PING_INTERVAL) {
previousTime = millis();
// IP-адрес искомого устройства, может быть глобальным
IPAddress addr = IPAddress(192,168,88,148);
// Итерация по ARP-таблице
ip4_addr_t *ip;
struct netif *netif;
struct eth_addr *ethaddr;
bool found = false;
for(int i=0; iaddr & 0xFF) &&
addr[1] == (ip->addr >> 8 & 0xFF) &&
addr[2] == (ip->addr >> 16 & 0xFF) &&
addr[3] == (ip->addr >> 24 & 0xFF)) {
found = true;
}
}
}
// Очищаем ARP-таблицу
etharp_cleanup_netif(netif);
// Запускаем следующий раунд пингов
pinger.Ping(addr, 5, PING_INTERVAL / 10);
// Обрабатываем информацию
if(found) {
if(current_state == false) {
current_state = true;
Serial.println("Device on");
}
} else {
if(current_state == true) {
current_state = false;
Serial.println("Device off");
}
}
}
}
void setup() {
Serial.begin(115200);
WiFiManager wifiManager;
wifiManager.setDebugOutput(false);
wifiManager.autoConnect("habr-example", "supergeneral");
Serial.print("Connected! IP address: ");
Serial.println(WiFi.localIP());
}
void loop() {
pingTick();
}
Но что делать, если эти варианты по каким-то причинам не подходят?
Чуткий нюх
Микроконтроллер на базе ESP8266 может быть Wi-Fi-сниффером. У него можно включить неразборчивый режим (promiscuous mode) и собирать пролетающие мимо пакеты. Существует несколько репозиториев, в которых есть код запускающий сниффер на вашем ESP8266.
Телефоны с включенным Wi-Fi будут постоянно рассылать разные пакеты, и часть из них не будет иметь шифрования. Таким образом, можно определять наличие или отсутствие телефона в сети.
Несмотря на то, что способ надежный как швейцарские часы, у данного решения есть ряд проблем:
- если рядом множество Wi-Fi сетей, то поток пакетов будет большим, что потребует самодельного фильтра. Возможно этот фильтр будет медленнее, чем в lwIP.
- В этом режиме ESP8266 не имеет доступа в интернет, так как не подключена к Wi-Fi. Если вы хотели добавить погоду или синхронизацию с NTP — это будет затруднительно.
- Микроконтроллер может «не услышать» пакет от вашего устройства в силу физических причин, а так как пакет не предназначался микроконтроллеру, повторения не будет.
- Сниффер может не понравиться соседям, их друзьям или местным законам.
Но существует более простой, надежный и быстрый способ получения информации о подключенных устройствах.
Уведомления
Этот способ требует соответствующего сетевого оборудования. Если у ваш домашний роутер работает на OpenWRT или RouterOS, то он точно подойдет.
Данный способ построен на парсинге логов маршрутизатора. Маршрутизатор всегда знает MAC-адрес подключившегося и в большинстве случаев выдает адрес с помощью DHCP-сервера. Поэтому, логи маршрутизатора — это самый быстрый и самый надежный способ.
Для моего Mikrotik hAP ac lite лог подключения и отключения выглядит следующим образом. MAC-адреса вымышлены.
wireless,info 80:35:XX:XX:XX:XX@wlan2: disconnected, received deauth: sending station leaving (3)
wireless,info 80:35:XX:XX:XX:X@wlan2: connected, signal strength -44
Настраиваем логирование по метке wireless, info в удаленный порт. Для ускорения обработки на микроконтроллере задействуем протокол UDP. Настраиваем UDP-сервер следующим образом:
#include
WiFiUDP syslog;
void setup() {
// Общая инициализация опущена
syslog.begin(514);
}
Далее периодически опрашиваем UDP-сервер на предмет пришедших пакетов.
#define BUF_SIZE 4096
char str[BUF_SIZE];
String masterMac = "80:35:XX:XX:XX:XX";
void syslogTick() {
int packetSize = syslog.parsePacket();
if(packetSize > 0) {
int n = syslog.read(str, BUF_SIZE);
str[n] = '\0';
String syslog_str = String(str);
String mac = syslog_str.substring(14, 31);
String reason = syslog_str.substring(39);
bool connected = true;
if(reason.startsWith("disconnected")) {
connected = false;
}
if(mac != masterMac) {
return;
}
if(connected) {
Serial.println("Device connected!");
} else {
Serial.println("Device disconnected!");
}
}
}
Пакет содержит MAC-адрес и причину события. Достаточно »разобрать» пришедшую строку и записать состояние.
Этот способ, конечно, тоже обладает недостатком. Так, при перезагрузке микроконтроллера, потребуется узнать текущее состояние искомого устройства. Но для этого можно использовать способ с ICMP-запросом.
#include
#include
WiFiUDP syslog;
void setup () {
Serial.begin (115200);
WiFiManager wifiManager;
wifiManager.setDebugOutput (false);
wifiManager.autoConnect («habr-example», «supergeneral»);
Serial.print («Connected! IP address:»);
Serial.println (WiFi.localIP ());
syslog.begin (514);
}
#define BUF_SIZE 4096
char str[BUF_SIZE];
String masterMac = »80:35: XX: XX: XX: XX»;
void syslogTick () {
int packetSize = syslog.parsePacket ();
if (packetSize > 0) {
int n = syslog.read (str, BUF_SIZE);
str[n] = '\0';
String syslog_str = String (str);
String mac = syslog_str.substring (14, 31);
String reason = syslog_str.substring (39);
bool connected = true;
if (reason.startsWith («disconnected»)) {
connected = false;
}
if (connected) {
Serial.println («Device connected!»);
} else {
Serial.println («Device disconnected!»);
}
}
}
void loop () {
syslogTick ();
}