网络唤醒 WOL(Wake On LAN)可以让已经进入关机状态的设备通过局域网设备对其网卡发送命令,从关机状态唤醒转成开机状态。

1. 开启 WOL 功能

Windows 中,需要在 BIOS 中启用 WOL 功能,并进行相关配置。以华硕主板为例,配置过程如下。

1.1 在 BIOS 中启用 WOL 功能

根据华硕主板官方教程,开机并按 f2del 进入华硕主板 BIOS,进入高级选项 -> APM 配置 -> 开启 Power On By PCI-E ,如图 1.1 所示。

图1.1

1.2 关闭 Windows 快速启动功能

打开 控制面板->电源选项 -> 选择电源按钮功能 并关闭快速启动功能,如图 1.2 所示。

图1.2

1.3 允许网卡唤醒设备

打开 设备管理器->网络适配器 找到有线网卡并右键进入属性设置,如图 1.3 所示。

图1.3

电源管理选项卡中勾选图 1.4 所示选项。

图1.4

高级选项卡中开启有关网络唤醒的选项,关闭节能有关的选项,如图 1.5 所示。

图1.5

1.4 网卡 MAC 地址和 IP 地址

设置 -> 网络和 Internet -> 以太网 中可查看有线网卡的 MAC 地址IP 地址以供后续使用。

图1.6

2. 实现 WOL 功能

网络唤醒功能是通过局域网内的设备发送一个幻数据包 Magic Packet 到目标设备实现。幻数据包 是一个包含目标计算机 MAC 地址 的广播帧,其结构为 6 字节0xff 开头,紧接着是目标计算机的 6 字节MAC 地址,重复 16 次,最后是 可选6 字节 密码,如下所示:

 _________________________________________
| 字节1 | 字节2| 字节3 | 字节4 | 字节5 | 字节6|
_________________________________________
| 0xff | 0xff | 0xff | 0xff | 0xff | 0xff |
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
|MAC[0]|MAC[1]|MAC[2]|MAC[3]|MAC[4]|MAC[5]|
-----------------------------------------
可选的密码:
_________________________________________
|PASS0 |PASS1 |PASS2 |PASS3 |PASS4 |PASS5 |
-----------------------------------------

幻数据包 通常是使用 UDP 协议发送,发送 端口 并没有严格限制,通常是使用 端口 7端口 9 ,但 特权端口(0-1023)在某些网络环境(如企业防火墙、路由器ACL)可能默认阻止这些端口的 UDP 广播流量。

使用 Kotlin 实现一个唤醒设备应用,其核心的幻数据包的封装和发送代码如下所示。

/**
 * 发送幻数据包唤醒局域网中的设备
 * @param macAddress 目标设备的MAC地址(格式:"00:11:22:33:44:55")
 * @param ipAddress 广播地址,通常是 "255.255.255.255" 或具体子网的广播地址
 * @param port 发送端口,通常是 7 或 9,这里默认使用 4000
 */
suspend fun wakeOnLan(macAddress: String, ipAddress: String = "255.255.255.255", port: Int = 4000, onResult: ((Boolean) -> Unit)? = null) {
    withContext (Dispatchers.IO) {
        try {
            // 6字节的目标 MAC 地址
            val bytes = ByteArray(6)
            val mac = macAddress.split(":")
            for (i in 0 until 6) {
                bytes[i] = mac[i].toInt(16).toByte()
            }

            // 构造幻数据包(Magic Packet)
            // 1. 6 字节的 FF
            val buffer = ByteArray(6 + 16 * 6)
            for (i in 0 until 6) {
                buffer[i] = 0xFF.toByte()
            }
            // 2. 重复目标 MAC 地址 16 次
            for (i in 0 until 16) {
                System.arraycopy(bytes, 0, buffer, 6 + i * 6, 6)
            }

            // 发送 UDP 数据包
            val socket = DatagramSocket()
            socket.setBroadcast(true)
            val packet = DatagramPacket(
                buffer,
                buffer.size,
                InetAddress.getByName(ipAddress),
                port
            )
            socket.send(packet)
            socket.close()

            withContext(Dispatchers.Main) {
                onResult?.invoke(true)
            }
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            e.printStackTrace()
            withContext(Dispatchers.Main) {
                onResult?.invoke(false)
            }
        }
    }
}

为了使得应用更易于使用,可以使用快速访问设备控制器功能。下拉通知栏,点击设备控制器其效果如图 2.1 所示。

图2.1

创建服务并声明

<service
    android:name=".WOLControlService"
    android:label="Wake On Lan Controls"
    android:permission="android.permission.BIND_CONTROLS"
    android:exported="true">
    <intent-filter>
        <action android:name="android.service.controls.ControlsProviderService" />
    </intent-filter>
</service>

WOLControlService 完整代码如下:

/**
 * 外部设备控制器
 */
class WOLControlService : ControlsProviderService() {

    private val TAG = WOLControlService::class.java.simpleName
    private var mGlobalJob: Job? = null

    /**
     * 添加控制器时系统会调用 createPublisherForAllAvailable 方法来获取可用的设备列表
     * 使用 flowPublish 需要导入 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4")
     */
    override fun createPublisherForAllAvailable(): Flow.Publisher<Control> = flowPublish {
        createWolDeviceControls().forEach { send(it) }
    }

    /**
     * 返回给定 controlId 的有效 Publisher
     * 添加控制器后系统会调用 createPublisherFor 方法来创建指定设备的控制器
     */
    override fun createPublisherFor(controlIds: List<String?>): Flow.Publisher<Control> = flowPublish {
        controlIds.forEach {
            it?.takeIf { it.isNotBlank() }?.let { controlId ->
                try {
                    val devices = SharedPreferencesUtils.getWOLDeviceById(this@WOLControlService, controlId.toInt())
                    createControlByDevice(devices)?.let { send(it) }
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }
        }
    }

    @OptIn(DelicateCoroutinesApi::class)
    override fun performControlAction(controlId: String, action: ControlAction, consumer: Consumer<Int?>) {
        SharedPreferencesUtils.getWOLDeviceById(this@WOLControlService, controlId.toInt())?.let {
            mGlobalJob = GlobalScope.launch {
                WOLUtils.wakeOnLan(it.macAddress, it.ipAddress, it.port) { result ->
                    if (result) {
                        Log.e(TAG, "唤醒成功: ${it.deviceName}")
                    }
                }
                consumer.accept(ControlAction.RESPONSE_OK)
            }
        } ?: run {
            consumer.accept(ControlAction.RESPONSE_FAIL)
        }
    }

    /**
     * 创建设备管理列表, 用于告知系统可用的设备列表
     */
    private fun createWolDeviceControls(): List<Control> {
        val controls = mutableListOf<Control>()
        val devices = SharedPreferencesUtils.getWOLDevices(this)
        for (device in devices) {
            createControlByDevice(device)?.let { controls.add(it) }
        }
        return controls
    }

    private fun createControlByDevice(device: WolDevices?): Control? {
        return if (device == null) null else Control.StatefulBuilder(
            device.id.toString(),
            PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
        ).setTitle(device.deviceName)
            .setSubtitle(device.macAddress)
            .setDeviceType(DeviceTypes.TYPE_TV)
            .setStatus(Control.STATUS_OK)
            .setControlTemplate(StatelessTemplate(device.id.toString()))
            .build()
    }

    override fun onDestroy() {
        super.onDestroy()
        mGlobalJob?.cancel()
    }

}

3. WOL 常见问题

3.1 刚关机时能唤醒设备,时间一长就无法唤醒设备

  1. 检查唤醒 IP 是否使用的是设备 IP,路由器 IP 地址表租约一般为12小时,超过12小时后租约到期则无法通过设备 IP 地址唤醒,可使用本地广播地址 255.255.255.255 尝试是否能唤醒。
  2. 检查网卡属性 -> 高级选项卡中的节能模式是否关闭,若未关闭节能模式可能导致网卡休眠,无法唤醒设备。