Add config flow to Wake on LAN (#121605)

This commit is contained in:
G Johansson 2024-07-19 21:20:43 +02:00 committed by GitHub
parent 7e0970e917
commit 288faf48e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 483 additions and 22 deletions

View File

@ -6,12 +6,13 @@ import logging
import voluptuous as vol
import wakeonlan
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@ -43,7 +44,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if broadcast_port is not None:
service_kwargs["port"] = broadcast_port
_LOGGER.info(
_LOGGER.debug(
"Send magic packet to mac %s (broadcast: %s, port: %s)",
mac_address,
broadcast_address,
@ -62,3 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Wake on LAN component entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -0,0 +1,87 @@
"""Support for button entity in wake on lan."""
from __future__ import annotations
from functools import partial
import logging
from typing import Any
import wakeonlan
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Wake on LAN sensor entry."""
broadcast_address: str | None = entry.options.get(CONF_BROADCAST_ADDRESS)
broadcast_port: int | None = entry.options.get(CONF_BROADCAST_PORT)
mac_address: str = entry.options[CONF_MAC]
name: str = entry.title
async_add_entities(
[
WolSwitch(
name,
mac_address,
broadcast_address,
broadcast_port,
)
]
)
class WolSwitch(ButtonEntity):
"""Representation of a wake on lan button."""
_attr_name = None
def __init__(
self,
name: str,
mac_address: str,
broadcast_address: str | None,
broadcast_port: int | None,
) -> None:
"""Initialize the WOL button."""
self._mac_address = mac_address
self._broadcast_address = broadcast_address
self._broadcast_port = broadcast_port
self._attr_unique_id = dr.format_mac(mac_address)
self._attr_device_info = dr.DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._attr_unique_id)},
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Wake on LAN",
name=name,
)
async def async_press(self) -> None:
"""Press the button."""
service_kwargs: dict[str, Any] = {}
if self._broadcast_address is not None:
service_kwargs["ip_address"] = self._broadcast_address
if self._broadcast_port is not None:
service_kwargs["port"] = self._broadcast_port
_LOGGER.debug(
"Send magic packet to mac %s (broadcast: %s, port: %s)",
self._mac_address,
self._broadcast_address,
self._broadcast_port,
)
await self.hass.async_add_executor_job(
partial(wakeonlan.send_magic_packet, self._mac_address, **service_kwargs)
)

View File

@ -0,0 +1,80 @@
"""Config flow for Wake on lan integration."""
from collections.abc import Mapping
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowFormStep,
)
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
TextSelector,
)
from .const import DEFAULT_NAME, DOMAIN
async def validate(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate input setup."""
user_input = await validate_options(handler, user_input)
user_input[CONF_MAC] = dr.format_mac(user_input[CONF_MAC])
# Mac address needs to be unique
handler.parent_handler._async_abort_entries_match({CONF_MAC: user_input[CONF_MAC]}) # noqa: SLF001
return user_input
async def validate_options(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate input options."""
if CONF_BROADCAST_PORT in user_input:
# Convert float to int for broadcast port
user_input[CONF_BROADCAST_PORT] = int(user_input[CONF_BROADCAST_PORT])
return user_input
DATA_SCHEMA = {vol.Required(CONF_MAC): TextSelector()}
OPTIONS_SCHEMA = {
vol.Optional(CONF_BROADCAST_ADDRESS): TextSelector(),
vol.Optional(CONF_BROADCAST_PORT): NumberSelector(
NumberSelectorConfig(min=0, max=65535, step=1, mode=NumberSelectorMode.BOX)
),
}
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA),
validate_user_input=validate,
)
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
vol.Schema(OPTIONS_SCHEMA), validate_user_input=validate_options
),
}
class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Statistics."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
mac: str = options[CONF_MAC]
return f"{DEFAULT_NAME} {mac}"

View File

@ -1,3 +1,11 @@
"""Constants for the Wake-On-LAN component."""
from homeassistant.const import Platform
DOMAIN = "wake_on_lan"
PLATFORMS = [Platform.BUTTON]
CONF_OFF_ACTION = "turn_off"
DEFAULT_NAME = "Wake on LAN"
DEFAULT_PING_TIMEOUT = 1

View File

@ -2,6 +2,7 @@
"domain": "wake_on_lan",
"name": "Wake on LAN",
"codeowners": ["@ntilley905"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/wake_on_lan",
"iot_class": "local_push",
"requirements": ["wakeonlan==2.1.0"]

View File

@ -1,20 +1,56 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"step": {
"user": {
"data": {
"mac": "MAC address",
"broadcast_address": "Broadcast address",
"broadcast_port": "Broadcast port"
},
"data_description": {
"mac": "MAC address of the device to wake up.",
"broadcast_address": "The IP address of the host to send the magic packet to. Defaults to `255.255.255.255` and is normally not changed.",
"broadcast_port": "The port to send the magic packet to. Defaults to `9` and is normally not changed."
}
}
}
},
"options": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"step": {
"init": {
"data": {
"broadcast_address": "[%key:component::wake_on_lan::config::step::user::data::broadcast_address%]",
"broadcast_port": "[%key:component::wake_on_lan::config::step::user::data::broadcast_port%]"
},
"data_description": {
"broadcast_address": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_address%]",
"broadcast_port": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_port%]"
}
}
}
},
"services": {
"send_magic_packet": {
"name": "Send magic packet",
"description": "Sends a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities.",
"fields": {
"mac": {
"name": "MAC address",
"description": "MAC address of the device to wake up."
"name": "[%key:component::wake_on_lan::config::step::user::data::mac%]",
"description": "[%key:component::wake_on_lan::config::step::user::data_description::mac%]"
},
"broadcast_address": {
"name": "Broadcast address",
"description": "Broadcast IP where to send the magic packet."
"name": "[%key:component::wake_on_lan::config::step::user::data::broadcast_address%]",
"description": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_address%]"
},
"broadcast_port": {
"name": "Broadcast port",
"description": "Port where to send the magic packet."
"name": "[%key:component::wake_on_lan::config::step::user::data::broadcast_port%]",
"description": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_port%]"
}
}
}

View File

@ -27,15 +27,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
from .const import CONF_OFF_ACTION, DEFAULT_NAME, DEFAULT_PING_TIMEOUT, DOMAIN
_LOGGER = logging.getLogger(__name__)
CONF_OFF_ACTION = "turn_off"
DEFAULT_NAME = "Wake on LAN"
DEFAULT_PING_TIMEOUT = 1
PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_MAC): cv.string,
@ -48,10 +43,10 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
)
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up a wake on lan switch."""
@ -62,7 +57,7 @@ def setup_platform(
name: str = config[CONF_NAME]
off_action: list[Any] | None = config.get(CONF_OFF_ACTION)
add_entities(
async_add_entities(
[
WolSwitch(
hass,

View File

@ -625,6 +625,7 @@ FLOWS = {
"volumio",
"volvooncall",
"vulcan",
"wake_on_lan",
"wallbox",
"waqi",
"watttime",

View File

@ -6743,7 +6743,7 @@
"wake_on_lan": {
"name": "Wake on LAN",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_push"
},
"wallbox": {

View File

@ -3,13 +3,23 @@
from __future__ import annotations
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.wake_on_lan.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
DEFAULT_MAC = "00:01:02:03:04:05"
@pytest.fixture
def mock_send_magic_packet() -> AsyncMock:
def mock_send_magic_packet() -> Generator[AsyncMock]:
"""Mock magic packet."""
with patch("wakeonlan.send_magic_packet") as mock_send:
yield mock_send
@ -27,3 +37,48 @@ def mock_subprocess_call(subprocess_call_return_value: int) -> Generator[MagicMo
with patch("homeassistant.components.wake_on_lan.switch.sp.call") as mock_sp:
mock_sp.return_value = subprocess_call_return_value
yield mock_sp
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Automatically path uuid generator."""
with patch(
"homeassistant.components.wake_on_lan.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="get_config")
async def get_config_to_integration_load() -> dict[str, Any]:
"""Return configuration.
To override the config, tests can be marked with:
@pytest.mark.parametrize("get_config", [{...}])
"""
return {
CONF_MAC: DEFAULT_MAC,
CONF_BROADCAST_ADDRESS: "255.255.255.255",
CONF_BROADCAST_PORT: 9,
}
@pytest.fixture(name="loaded_entry")
async def load_integration(
hass: HomeAssistant, get_config: dict[str, Any]
) -> MockConfigEntry:
"""Set up the Statistics integration in Home Assistant."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title=f"Wake on LAN {DEFAULT_MAC}",
source=SOURCE_USER,
options=get_config,
entry_id="1",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry

View File

@ -0,0 +1,54 @@
"""The tests for the wake on lan button platform."""
from __future__ import annotations
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
async def test_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
loaded_entry: MockConfigEntry,
) -> None:
"""Test button default state."""
state = hass.states.get("button.wake_on_lan_00_01_02_03_04_05")
assert state is not None
assert state.state == STATE_UNKNOWN
entry = entity_registry.async_get("button.wake_on_lan_00_01_02_03_04_05")
assert entry
assert entry.unique_id == "00:01:02:03:04:05"
async def test_service_calls(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
loaded_entry: MockConfigEntry,
mock_send_magic_packet: AsyncMock,
) -> None:
"""Test service call."""
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
freezer.move_to(now)
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.wake_on_lan_00_01_02_03_04_05"},
blocking=True,
)
assert (
hass.states.get("button.wake_on_lan_00_01_02_03_04_05").state == now.isoformat()
)

View File

@ -0,0 +1,109 @@
"""Test the Scrape config flow."""
from __future__ import annotations
from unittest.mock import AsyncMock
from homeassistant import config_entries
from homeassistant.components.wake_on_lan.const import DOMAIN
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import DEFAULT_MAC
from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_MAC: DEFAULT_MAC,
CONF_BROADCAST_ADDRESS: "255.255.255.255",
CONF_BROADCAST_PORT: 9,
},
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["version"] == 1
assert result["options"] == {
CONF_MAC: DEFAULT_MAC,
CONF_BROADCAST_ADDRESS: "255.255.255.255",
CONF_BROADCAST_PORT: 9,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
"""Test options flow."""
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BROADCAST_ADDRESS: "192.168.255.255",
CONF_BROADCAST_PORT: 10,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_MAC: DEFAULT_MAC,
CONF_BROADCAST_ADDRESS: "192.168.255.255",
CONF_BROADCAST_PORT: 10,
}
await hass.async_block_till_done()
assert loaded_entry.options == {
CONF_MAC: DEFAULT_MAC,
CONF_BROADCAST_ADDRESS: "192.168.255.255",
CONF_BROADCAST_PORT: 10,
}
# Check the entity was updated, no new entity was created
assert len(hass.states.async_all()) == 1
state = hass.states.get("button.wake_on_lan_00_01_02_03_04_05")
assert state is not None
async def test_entry_already_exist(
hass: HomeAssistant, loaded_entry: MockConfigEntry
) -> None:
"""Test abort when entry already exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_MAC: DEFAULT_MAC,
CONF_BROADCAST_ADDRESS: "255.255.255.255",
CONF_BROADCAST_PORT: 9,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -8,9 +8,21 @@ import pytest
import voluptuous as vol
from homeassistant.components.wake_on_lan import DOMAIN, SERVICE_SEND_MAGIC_PACKET
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
"""Test unload an entry."""
assert loaded_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(loaded_entry.entry_id)
await hass.async_block_till_done()
assert loaded_entry.state is ConfigEntryState.NOT_LOADED
async def test_send_magic_packet(hass: HomeAssistant) -> None:
"""Test of send magic packet service call."""

View File

@ -13,6 +13,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from tests.common import async_mock_service
@ -64,7 +65,7 @@ async def test_broadcast_config_ip_and_port(
hass: HomeAssistant, mock_send_magic_packet: AsyncMock
) -> None:
"""Test with broadcast address and broadcast port config."""
mac = "00-01-02-03-04-05"
mac = "00:01:02:03:04:05"
broadcast_address = "255.255.255.255"
port = 999
@ -92,6 +93,7 @@ async def test_broadcast_config_ip_and_port(
blocking=True,
)
mac = dr.format_mac(mac)
mock_send_magic_packet.assert_called_with(
mac, ip_address=broadcast_address, port=port
)
@ -102,7 +104,7 @@ async def test_broadcast_config_ip(
) -> None:
"""Test with only broadcast address."""
mac = "00-01-02-03-04-05"
mac = "00:01:02:03:04:05"
broadcast_address = "255.255.255.255"
assert await async_setup_component(
@ -128,6 +130,7 @@ async def test_broadcast_config_ip(
blocking=True,
)
mac = dr.format_mac(mac)
mock_send_magic_packet.assert_called_with(mac, ip_address=broadcast_address)
@ -136,7 +139,7 @@ async def test_broadcast_config_port(
) -> None:
"""Test with only broadcast port config."""
mac = "00-01-02-03-04-05"
mac = "00:01:02:03:04:05"
port = 999
assert await async_setup_component(
@ -156,6 +159,7 @@ async def test_broadcast_config_port(
blocking=True,
)
mac = dr.format_mac(mac)
mock_send_magic_packet.assert_called_with(mac, port=port)