Выключаем компьютер через Wake-on-Lan
Wake-on-Lan (WoL) — технология, которая используется (как и следует из названия) для включения компьютера посредством отправки специального пакета на адрес требуемого хоста. Но что если при помощи WoL хочется ещё и выключать компьютер?
По сути весь дальнейший текст — это ещё один способ превратить буханку хлеба в троллейбус. Но если очень хочется, то почему нет?
В чём суть?
Я активно использую WoL для включения домашнего компьютера через смартфон и практически не использую физическую кнопку на корпусе ПК. Приложение для отправки WoL-пакета, которое я использую — это первое приложение, которое вылезло в Play Market’е, когда я вообще решил реализовывать эту затею. И что мне особенно в нём нравится, так это то, что у этого приложения ровно одна функция — отправить пакет на ПК. Всё.
После минутной настройки компьютер включается по нажатию одной кнопки в приложении из любой точки локальной сети. А если внести пару изменений в конфиг роутера, то и из любой точки мира, где есть интернет.
Но через какое-то время появилась потребность так же легко по нажатию одной кнопки выключать компьютер. Беглый гуглёж в русскоязычном и англоязычном интернетах подсказал, что для выключения можно поставить на компьютер какой-нибудь агент (вариантов много) и клиентское приложение на телефон и далее можно будет хоть включать ПК, хоть выключать, хоть получать доступ к файлам и много всего остального.
Так как из вышеперечисленного меня интересовали только включение и выключение, а менять приложение, к которому я привык и тем более ставить агенты на ПК мне очень не хотелось, я подумал, а нельзя ли использовать функционал WoL для выключения?
Поиск решения
В общем, после ещё некоторого гугления выяснилось, что технология WoL подходит только для включения и на этом его полномочия всё. А менять приложение и что-то устанавливать, как уже говорил, ну совсем не хотелось и тут мелькнула простая, как топор, мысль: «А почему бы не заставить компьютер считать, что в случае, если приходит WoL-пакет, то это знак, что ему пора бы выключиться?».
Для реализации подобных вещей идеально подходит Python, так что открываем IDE и начинаем менять смысл WoL на прямо противоположный.
Проектируем
Для реализации задуманного сначала нужно разобраться, что такое WoL-пакет и из чего он сделан.
WoL-пакет или по-другому magic packet отправляется посредством UDP чаще всего на 7 или 9 порт, «весит» 102 байта и состоит из следующих частей:
Символ 0xFF, повторяющийся 6 раз. Итого 6 байт;
MAC-адрес целевого устройства, повторяющийся 16 раз. Размер MAC-адреса 6 байт, итого 96 байт.
Теперь, зная это, план следующий:
Определить сетевой интерфейс, на который будем отправлять WoL-пакеты и выбрать номер порта. Интерфейс желательно выбрать, т.к. на ПК может быть несколько сетевых интерфейсов, каждый со своим адресом;
Получить IP и MAC-адреса выбранного интерфейса;
Самостоятельно вычислить правильный WoL-пакет для выбранного интерфейса;
Запустить прослушивание UDP-порта, куда собираемся отправлять WoL-пакеты;
Декодировать полученные данные и затем сравнить их с тем, что получилось в п. 3 и если данные совпадают, то запустить команду завершения работы;
Profit!
Реализация
Приступаем к реализации.
С первым пунктом всё просто. Название интерфейса можно посмотреть где-нибудь в настройках операционной системы. В моём случае — это Ethernet 4. Номер порта для WoL-пакетов, как уже говорилось выше, чаще всего 7 и 9. Я буду использовать 9.
WOL_PORT = 9
INTERFACE_NAME = 'Ethernet 4'
Пункт второй. Для получения IP и MAC-адресов понадобится библиотека psutil (больше никаких зависимостей устанавливать не будем).
pip install psutil
Затем пишем код для получения адресов:
import psutil
def get_ip_mac_address(interface_name: str) -> tuple:
ip_addr = mac_addr = None
for item in psutil.net_if_addrs()[interface_name]:
addr = item.address
# В IPv4-адресах разделители - точки
if '.' in addr:
ip_addr = addr
# В MAC-адресах разделители либо тире, либо одинарное двоеточие.
# Двойное двоеточие - это разделители для адресов IPv6
elif ('-' in addr or ':' in item) and '::' not in addr:
# Приводим MAC-адрес к одному формату. Формат может меняться в зависимости от ОС
mac_addr = addr.replace(':', '-').upper()
if not ip_addr or not mac_addr or ip_addr == '127.0.0.1':
raise 'Не удалось получить IP или MAC-адрес сетевого интерфейса'
return ip_addr, mac_addr
Мелкие пояснения по коду есть в самом коде, но некоторого описания требует строка 6.
Функция psutil.net_if_addrs()[interface_name]
возвращает список именованных кортежей c различными параметрами для каждого адреса сетевого интерфейса. Порядок этих кортежей разный для разных ОС (я тестировал на Windows 10 и Ubuntu 20.04), соответственно на выборку по индексам рассчитывать не стоит, поэтому запускаем цикл по адресам интерфейса и парой условных операторов идентифицируем кто есть кто.
(Уже постфактум я подумал, что можно было для этого использовать значение family, но переписывать было лень).
Со вторым пунктом разобрались, теперь пункт три. Тут всё просто, почти как в первом.
Используем полученный MAC-адрес и собираем магический пакет:
def assemble_wol_packet(mac_address: str) -> str:
return f'{"FF-" * 6}{(mac_address + "-") * 16}'
Четвёртый пункт не сильно сложнее остальных.
Запускаем процесс прослушивания указанного порта с полученным IP-адресом интерфейса.
На строке 4 используется функция из пункта 2, а на строке 9 — функция из пункта 3
import socket
def run_udp_port_listener(port: int, interface_name: str):
ip_addr, mac_addr = get_ip_mac_address(interface_name)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind((ip_addr, port))
assembled_wol_packet = assemble_wol_packet(mac_addr)
while True:
data, _ = server_socket.recvfrom(1024)
И завершающий пункт — как обычно, ничего сложного.
Для начала декодируем данные:
decoded_packet_data = '-'.join(f'{byte:02x}' for byte in raw_bytes).upper() + '-'
Немного пояснений: полученные данные — это набор байт, так что сначала приводим данные к шестнадцатеричному виду при помощи команды byte:02x
. Далее то, что получилось разделяем через знак »-» и в конце приводим к верхнему регистру.
По идее без знака тире, что здесь, что в третьем пункте можно обойтись, но с ним проще проводить отладку, если вдруг что-то идёт не так.
Затем осталось сравнить данные и запустить команду выключения ПК. Для удобства лучше обернуть декодирование и сравнение в одну функцию:
def check_is_wol_packet(raw_bytes: bytes, assembled_wol_packet: str) -> int:
decoded_packet_data = '-'.join(f'{byte:02x}' for byte in raw_bytes).upper() + '-'
if decoded_packet_data == assembled_wol_packet:
return 1
return 0
В случае совпадения данных инициируем shutdown:
import os
# ... Здесь код запуска прослушивания порта ...
while True:
data, _ = server_socket.recvfrom(1024)
is_wol_packet = check_is_wol_packet(data, assembled_wol_packet)
if is_wol_packet == 1:
if os.name == 'posix':
os.system('sudo shutdown -h now')
elif os.name == 'nt':
os.system('shutdown -s -t 0 -f')
И теперь соберём весь код вместе
import socket
import os
import logging
import psutil
WOL_PORT = 9
INTERFACE_NAME = 'Ethernet 4'
logging.basicConfig(format='%(levelname)s: %(asctime)s %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
def get_ip_mac_address(interface_name: str) -> tuple:
ip_addr = mac_addr = None
for item in psutil.net_if_addrs()[interface_name]:
addr = item.address
# В IPv4-адресах разделители - точки
if '.' in addr:
ip_addr = addr
# В MAC-адресах разделители либо тире, либо одинарное двоеточие.
# Двойное двоеточие - это разделители для адресов IPv6
elif ('-' in addr or ':' in item) and '::' not in addr:
# Приводим MAC-адрес к одному формату. Формат может меняться в зависимости от ОС
mac_addr = addr.replace(':', '-').upper()
if not ip_addr or not mac_addr or ip_addr == '127.0.0.1':
raise 'Не удалось получить IP или MAC-адрес сетевого интерфейса'
return ip_addr, mac_addr
def assemble_wol_packet(mac_address: str) -> str:
return f'{"FF-" * 6}{(mac_address + "-") * 16}'
def check_is_wol_packet(raw_bytes: bytes, assembled_wol_packet: str) -> int:
decoded_packet_data = '-'.join(f'{byte:02x}' for byte in raw_bytes).upper() + '-'
if decoded_packet_data == assembled_wol_packet:
return 1
return 0
def run_udp_port_listener(port: int, interface_name: str):
ip_addr, mac_addr = get_ip_mac_address(interface_name)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind((ip_addr, port))
logger.info(f'Listening on {ip_addr}:{port}')
assembled_wol_packet = assemble_wol_packet(mac_addr)
while True:
data, _ = server_socket.recvfrom(1024)
is_wol_packet = check_is_wol_packet(data, assembled_wol_packet)
if is_wol_packet == 1:
if os.name == 'posix':
os.system('sudo shutdown -h now')
elif os.name == 'nt':
os.system('shutdown -s -t 0 -f')
run_udp_port_listener(WOL_PORT, INTERFACE_NAME)
Теперь если всё сделано верно, то при отправке WoL-пакета на IP-адрес ПК он выключится. Если при этом немного поколдовать с роутером, то это получится делать и через интернет.
Далее можно добавить данный скрипт в автозагрузку или настроить службу, инструкций для этого в интернете хватает для любой ОС, так что про реализацию добавить больше нечего.
Итого
Конечно, вышеприведённый код не идеален. Его можно написать лучше, добавить различные проверки для безопасности и сделать много других вещей, но лично для меня это уже ненужное усложнение. То что было нужно мне я реализовал и думаю, что на этом можно и закончить.