Fix Rainbird unique to use a more reliable source (mac address) (#101603)
* Fix rainbird unique id to use a mac address for new entries * Fix typo * Normalize mac address before using as unique id * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update test check and remove dead code * Update all config entries to the new format * Update config entry tests for migration * Fix rainbird entity unique ids * Add test coverage for repair failure * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Remove unnecessary migration failure checks * Remove invalid config entries * Update entry when entering a different hostname for an existing host. --------- Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
48a8ae4df5
commit
25650563fe
10 changed files with 515 additions and 161 deletions
|
@ -1,17 +1,25 @@
|
||||||
"""Support for Rain Bird Irrigation system LNK WiFi Module."""
|
"""Support for Rain Bird Irrigation system LNK WiFi Module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
|
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
|
||||||
from pyrainbird.exceptions import RainbirdApiException
|
from pyrainbird.exceptions import RainbirdApiException
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
|
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
from homeassistant.helpers.entity_registry import async_entries_for_config_entry
|
||||||
|
|
||||||
|
from .const import CONF_SERIAL_NUMBER
|
||||||
from .coordinator import RainbirdData
|
from .coordinator import RainbirdData
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
|
@ -36,6 +44,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
entry.data[CONF_PASSWORD],
|
entry.data[CONF_PASSWORD],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not (await _async_fix_unique_id(hass, controller, entry)):
|
||||||
|
return False
|
||||||
|
if mac_address := entry.data.get(CONF_MAC):
|
||||||
|
_async_fix_entity_unique_id(
|
||||||
|
hass,
|
||||||
|
er.async_get(hass),
|
||||||
|
entry.entry_id,
|
||||||
|
format_mac(mac_address),
|
||||||
|
str(entry.data[CONF_SERIAL_NUMBER]),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model_info = await controller.get_model_and_version()
|
model_info = await controller.get_model_and_version()
|
||||||
except RainbirdApiException as err:
|
except RainbirdApiException as err:
|
||||||
|
@ -51,6 +71,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_fix_unique_id(
|
||||||
|
hass: HomeAssistant, controller: AsyncRainbirdController, entry: ConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Update the config entry with a unique id based on the mac address."""
|
||||||
|
_LOGGER.debug("Checking for migration of config entry (%s)", entry.unique_id)
|
||||||
|
if not (mac_address := entry.data.get(CONF_MAC)):
|
||||||
|
try:
|
||||||
|
wifi_params = await controller.get_wifi_params()
|
||||||
|
except RainbirdApiException as err:
|
||||||
|
_LOGGER.warning("Unable to fix missing unique id: %s", err)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if (mac_address := wifi_params.mac_address) is None:
|
||||||
|
_LOGGER.warning("Unable to fix missing unique id (mac address was None)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
new_unique_id = format_mac(mac_address)
|
||||||
|
if entry.unique_id == new_unique_id and CONF_MAC in entry.data:
|
||||||
|
_LOGGER.debug("Config entry already in correct state")
|
||||||
|
return True
|
||||||
|
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
for existing_entry in entries:
|
||||||
|
if existing_entry.unique_id == new_unique_id:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unable to fix missing unique id (already exists); Removing duplicate entry"
|
||||||
|
)
|
||||||
|
hass.async_create_background_task(
|
||||||
|
hass.config_entries.async_remove(entry.entry_id),
|
||||||
|
"Remove rainbird config entry",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
_LOGGER.debug("Updating unique id to %s", new_unique_id)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
unique_id=new_unique_id,
|
||||||
|
data={
|
||||||
|
**entry.data,
|
||||||
|
CONF_MAC: mac_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _async_fix_entity_unique_id(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
config_entry_id: str,
|
||||||
|
mac_address: str,
|
||||||
|
serial_number: str,
|
||||||
|
) -> None:
|
||||||
|
"""Migrate existing entity if current one can't be found and an old one exists."""
|
||||||
|
entity_entries = async_entries_for_config_entry(entity_registry, config_entry_id)
|
||||||
|
for entity_entry in entity_entries:
|
||||||
|
unique_id = str(entity_entry.unique_id)
|
||||||
|
if unique_id.startswith(mac_address):
|
||||||
|
continue
|
||||||
|
if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id:
|
||||||
|
new_unique_id = f"{mac_address}{suffix}"
|
||||||
|
_LOGGER.debug("Updating unique id from %s to %s", unique_id, new_unique_id)
|
||||||
|
entity_registry.async_update_entity(
|
||||||
|
entity_entry.entity_id, new_unique_id=new_unique_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
|
|
||||||
|
|
|
@ -11,15 +11,17 @@ from pyrainbird.async_client import (
|
||||||
AsyncRainbirdController,
|
AsyncRainbirdController,
|
||||||
RainbirdApiException,
|
RainbirdApiException,
|
||||||
)
|
)
|
||||||
|
from pyrainbird.data import WifiParams
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers import config_validation as cv, selector
|
from homeassistant.helpers import config_validation as cv, selector
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_DURATION,
|
ATTR_DURATION,
|
||||||
|
@ -69,7 +71,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
error_code: str | None = None
|
error_code: str | None = None
|
||||||
if user_input:
|
if user_input:
|
||||||
try:
|
try:
|
||||||
serial_number = await self._test_connection(
|
serial_number, wifi_params = await self._test_connection(
|
||||||
user_input[CONF_HOST], user_input[CONF_PASSWORD]
|
user_input[CONF_HOST], user_input[CONF_PASSWORD]
|
||||||
)
|
)
|
||||||
except ConfigFlowError as err:
|
except ConfigFlowError as err:
|
||||||
|
@ -77,11 +79,11 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
error_code = err.error_code
|
error_code = err.error_code
|
||||||
else:
|
else:
|
||||||
return await self.async_finish(
|
return await self.async_finish(
|
||||||
serial_number,
|
|
||||||
data={
|
data={
|
||||||
CONF_HOST: user_input[CONF_HOST],
|
CONF_HOST: user_input[CONF_HOST],
|
||||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||||
CONF_SERIAL_NUMBER: serial_number,
|
CONF_SERIAL_NUMBER: serial_number,
|
||||||
|
CONF_MAC: wifi_params.mac_address,
|
||||||
},
|
},
|
||||||
options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES},
|
options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES},
|
||||||
)
|
)
|
||||||
|
@ -92,8 +94,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
errors={"base": error_code} if error_code else None,
|
errors={"base": error_code} if error_code else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _test_connection(self, host: str, password: str) -> str:
|
async def _test_connection(
|
||||||
"""Test the connection and return the device serial number.
|
self, host: str, password: str
|
||||||
|
) -> tuple[str, WifiParams]:
|
||||||
|
"""Test the connection and return the device identifiers.
|
||||||
|
|
||||||
Raises a ConfigFlowError on failure.
|
Raises a ConfigFlowError on failure.
|
||||||
"""
|
"""
|
||||||
|
@ -106,7 +110,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(TIMEOUT_SECONDS):
|
async with asyncio.timeout(TIMEOUT_SECONDS):
|
||||||
return await controller.get_serial_number()
|
return await asyncio.gather(
|
||||||
|
controller.get_serial_number(),
|
||||||
|
controller.get_wifi_params(),
|
||||||
|
)
|
||||||
except asyncio.TimeoutError as err:
|
except asyncio.TimeoutError as err:
|
||||||
raise ConfigFlowError(
|
raise ConfigFlowError(
|
||||||
f"Timeout connecting to Rain Bird controller: {str(err)}",
|
f"Timeout connecting to Rain Bird controller: {str(err)}",
|
||||||
|
@ -120,18 +127,28 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
async def async_finish(
|
async def async_finish(
|
||||||
self,
|
self,
|
||||||
serial_number: str,
|
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
options: dict[str, Any],
|
options: dict[str, Any],
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Create the config entry."""
|
"""Create the config entry."""
|
||||||
# Prevent devices with the same serial number. If the device does not have a serial number
|
# The integration has historically used a serial number, but not all devices
|
||||||
# then we can at least prevent configuring the same host twice.
|
# historically had a valid one. Now the mac address is used as a unique id
|
||||||
if serial_number:
|
# and serial is still persisted in config entry data in case it is needed
|
||||||
await self.async_set_unique_id(serial_number)
|
# in the future.
|
||||||
self._abort_if_unique_id_configured()
|
# Either way, also prevent configuring the same host twice.
|
||||||
else:
|
await self.async_set_unique_id(format_mac(data[CONF_MAC]))
|
||||||
self._async_abort_entries_match(data)
|
self._abort_if_unique_id_configured(
|
||||||
|
updates={
|
||||||
|
CONF_HOST: data[CONF_HOST],
|
||||||
|
CONF_PASSWORD: data[CONF_PASSWORD],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._async_abort_entries_match(
|
||||||
|
{
|
||||||
|
CONF_HOST: data[CONF_HOST],
|
||||||
|
CONF_PASSWORD: data[CONF_PASSWORD],
|
||||||
|
}
|
||||||
|
)
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=data[CONF_HOST],
|
title=data[CONF_HOST],
|
||||||
data=data,
|
data=data,
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
@ -24,6 +25,8 @@ HOST = "example.com"
|
||||||
URL = "http://example.com/stick"
|
URL = "http://example.com/stick"
|
||||||
PASSWORD = "password"
|
PASSWORD = "password"
|
||||||
SERIAL_NUMBER = 0x12635436566
|
SERIAL_NUMBER = 0x12635436566
|
||||||
|
MAC_ADDRESS = "4C:A1:61:00:11:22"
|
||||||
|
MAC_ADDRESS_UNIQUE_ID = "4c:a1:61:00:11:22"
|
||||||
|
|
||||||
#
|
#
|
||||||
# Response payloads below come from pyrainbird test cases.
|
# Response payloads below come from pyrainbird test cases.
|
||||||
|
@ -50,6 +53,20 @@ RAIN_DELAY = "B60010" # 0x10 is 16
|
||||||
RAIN_DELAY_OFF = "B60000"
|
RAIN_DELAY_OFF = "B60000"
|
||||||
# ACK command 0x10, Echo 0x06
|
# ACK command 0x10, Echo 0x06
|
||||||
ACK_ECHO = "0106"
|
ACK_ECHO = "0106"
|
||||||
|
WIFI_PARAMS_RESPONSE = {
|
||||||
|
"macAddress": MAC_ADDRESS,
|
||||||
|
"localIpAddress": "1.1.1.38",
|
||||||
|
"localNetmask": "255.255.255.0",
|
||||||
|
"localGateway": "1.1.1.1",
|
||||||
|
"rssi": -61,
|
||||||
|
"wifiSsid": "wifi-ssid-name",
|
||||||
|
"wifiPassword": "wifi-password-name",
|
||||||
|
"wifiSecurity": "wpa2-aes",
|
||||||
|
"apTimeoutNoLan": 20,
|
||||||
|
"apTimeoutIdle": 20,
|
||||||
|
"apSecurity": "unknown",
|
||||||
|
"stickVersion": "Rain Bird Stick Rev C/1.63",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
|
@ -62,10 +79,16 @@ CONFIG = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT = {
|
||||||
|
"host": HOST,
|
||||||
|
"password": PASSWORD,
|
||||||
|
"serial_number": SERIAL_NUMBER,
|
||||||
|
}
|
||||||
CONFIG_ENTRY_DATA = {
|
CONFIG_ENTRY_DATA = {
|
||||||
"host": HOST,
|
"host": HOST,
|
||||||
"password": PASSWORD,
|
"password": PASSWORD,
|
||||||
"serial_number": SERIAL_NUMBER,
|
"serial_number": SERIAL_NUMBER,
|
||||||
|
"mac": MAC_ADDRESS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -77,14 +100,23 @@ def platforms() -> list[Platform]:
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def config_entry_unique_id() -> str:
|
async def config_entry_unique_id() -> str:
|
||||||
"""Fixture for serial number used in the config entry."""
|
"""Fixture for config entry unique id."""
|
||||||
|
return MAC_ADDRESS_UNIQUE_ID
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def serial_number() -> int:
|
||||||
|
"""Fixture for serial number used in the config entry data."""
|
||||||
return SERIAL_NUMBER
|
return SERIAL_NUMBER
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def config_entry_data() -> dict[str, Any]:
|
async def config_entry_data(serial_number: int) -> dict[str, Any]:
|
||||||
"""Fixture for MockConfigEntry data."""
|
"""Fixture for MockConfigEntry data."""
|
||||||
return CONFIG_ENTRY_DATA
|
return {
|
||||||
|
**CONFIG_ENTRY_DATA,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -123,17 +155,24 @@ def setup_platforms(
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
def rainbird_response(data: str) -> bytes:
|
def rainbird_json_response(result: dict[str, str]) -> bytes:
|
||||||
"""Create a fake API response."""
|
"""Create a fake API response."""
|
||||||
return encryption.encrypt(
|
return encryption.encrypt(
|
||||||
'{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data,
|
'{"jsonrpc": "2.0", "result": %s, "id": 1} ' % json.dumps(result),
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mock_json_response(result: dict[str, str]) -> AiohttpClientMockResponse:
|
||||||
|
"""Create a fake AiohttpClientMockResponse."""
|
||||||
|
return AiohttpClientMockResponse(
|
||||||
|
"POST", URL, response=rainbird_json_response(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mock_response(data: str) -> AiohttpClientMockResponse:
|
def mock_response(data: str) -> AiohttpClientMockResponse:
|
||||||
"""Create a fake AiohttpClientMockResponse."""
|
"""Create a fake AiohttpClientMockResponse."""
|
||||||
return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data))
|
return mock_json_response({"data": data})
|
||||||
|
|
||||||
|
|
||||||
def mock_response_error(
|
def mock_response_error(
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
"""Tests for rainbird sensor platform."""
|
"""Tests for rainbird sensor platform."""
|
||||||
|
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
@ -8,7 +10,12 @@ from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER
|
from .conftest import (
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
RAIN_SENSOR_OFF,
|
||||||
|
RAIN_SENSOR_ON,
|
||||||
|
mock_response_error,
|
||||||
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
||||||
|
@ -51,47 +58,25 @@ async def test_rainsensor(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("config_entry_unique_id", "entity_unique_id"),
|
("config_entry_data", "config_entry_unique_id", "setup_config_entry"),
|
||||||
[
|
[
|
||||||
(SERIAL_NUMBER, "1263613994342-rainsensor"),
|
(CONFIG_ENTRY_DATA_OLD_FORMAT, None, None),
|
||||||
# Some existing config entries may have a "0" serial number but preserve
|
|
||||||
# their unique id
|
|
||||||
(0, "0-rainsensor"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_unique_id(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entity_registry: er.EntityRegistry,
|
|
||||||
entity_unique_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Test rainsensor binary sensor."""
|
|
||||||
rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor")
|
|
||||||
assert rainsensor is not None
|
|
||||||
assert rainsensor.attributes == {
|
|
||||||
"friendly_name": "Rain Bird Controller Rainsensor",
|
|
||||||
"icon": "mdi:water",
|
|
||||||
}
|
|
||||||
|
|
||||||
entity_entry = entity_registry.async_get(
|
|
||||||
"binary_sensor.rain_bird_controller_rainsensor"
|
|
||||||
)
|
|
||||||
assert entity_entry
|
|
||||||
assert entity_entry.unique_id == entity_unique_id
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("config_entry_unique_id"),
|
|
||||||
[
|
|
||||||
(None),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_no_unique_id(
|
async def test_no_unique_id(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
responses: list[AiohttpClientMockResponse],
|
responses: list[AiohttpClientMockResponse],
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test rainsensor binary sensor with no unique id."""
|
"""Test rainsensor binary sensor with no unique id."""
|
||||||
|
|
||||||
|
# Failure to migrate config entry to a unique id
|
||||||
|
responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE))
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor")
|
rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor")
|
||||||
assert rainsensor is not None
|
assert rainsensor is not None
|
||||||
assert (
|
assert (
|
||||||
|
|
|
@ -17,7 +17,7 @@ from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .conftest import mock_response, mock_response_error
|
from .conftest import CONFIG_ENTRY_DATA_OLD_FORMAT, mock_response, mock_response_error
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
||||||
|
@ -210,7 +210,7 @@ async def test_event_state(
|
||||||
|
|
||||||
entity = entity_registry.async_get(TEST_ENTITY)
|
entity = entity_registry.async_get(TEST_ENTITY)
|
||||||
assert entity
|
assert entity
|
||||||
assert entity.unique_id == 1263613994342
|
assert entity.unique_id == "4c:a1:61:00:11:22"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
@ -280,18 +280,26 @@ async def test_program_schedule_disabled(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("config_entry_unique_id"),
|
("config_entry_data", "config_entry_unique_id", "setup_config_entry"),
|
||||||
[
|
[
|
||||||
(None),
|
(CONFIG_ENTRY_DATA_OLD_FORMAT, None, None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_no_unique_id(
|
async def test_no_unique_id(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
get_events: GetEventsFn,
|
get_events: GetEventsFn,
|
||||||
|
responses: list[AiohttpClientMockResponse],
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test calendar entity with no unique id."""
|
"""Test calendar entity with no unique id."""
|
||||||
|
|
||||||
|
# Failure to migrate config entry to a unique id
|
||||||
|
responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE))
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
state = hass.states.get(TEST_ENTITY)
|
state = hass.states.get(TEST_ENTITY)
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.attributes.get("friendly_name") == "Rain Bird Controller"
|
assert state.attributes.get("friendly_name") == "Rain Bird Controller"
|
||||||
|
|
|
@ -19,11 +19,14 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
CONFIG_ENTRY_DATA,
|
CONFIG_ENTRY_DATA,
|
||||||
HOST,
|
HOST,
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
SERIAL_NUMBER,
|
SERIAL_NUMBER,
|
||||||
SERIAL_RESPONSE,
|
SERIAL_RESPONSE,
|
||||||
URL,
|
URL,
|
||||||
|
WIFI_PARAMS_RESPONSE,
|
||||||
ZERO_SERIAL_RESPONSE,
|
ZERO_SERIAL_RESPONSE,
|
||||||
|
mock_json_response,
|
||||||
mock_response,
|
mock_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -34,7 +37,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockRespon
|
||||||
@pytest.fixture(name="responses")
|
@pytest.fixture(name="responses")
|
||||||
def mock_responses() -> list[AiohttpClientMockResponse]:
|
def mock_responses() -> list[AiohttpClientMockResponse]:
|
||||||
"""Set up fake serial number response when testing the connection."""
|
"""Set up fake serial number response when testing the connection."""
|
||||||
return [mock_response(SERIAL_RESPONSE)]
|
return [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
@ -74,14 +77,20 @@ async def complete_flow(hass: HomeAssistant) -> FlowResult:
|
||||||
("responses", "expected_config_entry", "expected_unique_id"),
|
("responses", "expected_config_entry", "expected_unique_id"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
[mock_response(SERIAL_RESPONSE)],
|
[
|
||||||
|
mock_response(SERIAL_RESPONSE),
|
||||||
|
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||||
|
],
|
||||||
CONFIG_ENTRY_DATA,
|
CONFIG_ENTRY_DATA,
|
||||||
SERIAL_NUMBER,
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
[mock_response(ZERO_SERIAL_RESPONSE)],
|
[
|
||||||
|
mock_response(ZERO_SERIAL_RESPONSE),
|
||||||
|
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||||
|
],
|
||||||
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
||||||
None,
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -115,17 +124,32 @@ async def test_controller_flow(
|
||||||
(
|
(
|
||||||
"other-serial-number",
|
"other-serial-number",
|
||||||
{**CONFIG_ENTRY_DATA, "host": "other-host"},
|
{**CONFIG_ENTRY_DATA, "host": "other-host"},
|
||||||
[mock_response(SERIAL_RESPONSE)],
|
[mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)],
|
||||||
|
CONFIG_ENTRY_DATA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"11:22:33:44:55:66",
|
||||||
|
{
|
||||||
|
**CONFIG_ENTRY_DATA,
|
||||||
|
"host": "other-host",
|
||||||
|
},
|
||||||
|
[
|
||||||
|
mock_response(SERIAL_RESPONSE),
|
||||||
|
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||||
|
],
|
||||||
CONFIG_ENTRY_DATA,
|
CONFIG_ENTRY_DATA,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{**CONFIG_ENTRY_DATA, "serial_number": 0, "host": "other-host"},
|
{**CONFIG_ENTRY_DATA, "serial_number": 0, "host": "other-host"},
|
||||||
[mock_response(ZERO_SERIAL_RESPONSE)],
|
[
|
||||||
|
mock_response(ZERO_SERIAL_RESPONSE),
|
||||||
|
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||||
|
],
|
||||||
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
ids=["with-serial", "zero-serial"],
|
ids=["with-serial", "with-mac-address", "zero-serial"],
|
||||||
)
|
)
|
||||||
async def test_multiple_config_entries(
|
async def test_multiple_config_entries(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -154,22 +178,52 @@ async def test_multiple_config_entries(
|
||||||
"config_entry_unique_id",
|
"config_entry_unique_id",
|
||||||
"config_entry_data",
|
"config_entry_data",
|
||||||
"config_flow_responses",
|
"config_flow_responses",
|
||||||
|
"expected_config_entry_data",
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
|
# Config entry is a pure duplicate with the same mac address unique id
|
||||||
|
(
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
|
CONFIG_ENTRY_DATA,
|
||||||
|
[
|
||||||
|
mock_response(SERIAL_RESPONSE),
|
||||||
|
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||||
|
],
|
||||||
|
CONFIG_ENTRY_DATA,
|
||||||
|
),
|
||||||
|
# Old unique id with serial, but same host
|
||||||
(
|
(
|
||||||
SERIAL_NUMBER,
|
SERIAL_NUMBER,
|
||||||
CONFIG_ENTRY_DATA,
|
CONFIG_ENTRY_DATA,
|
||||||
[mock_response(SERIAL_RESPONSE)],
|
[mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)],
|
||||||
|
CONFIG_ENTRY_DATA,
|
||||||
),
|
),
|
||||||
|
# Old unique id with no serial, but same host
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
||||||
[mock_response(ZERO_SERIAL_RESPONSE)],
|
[
|
||||||
|
mock_response(ZERO_SERIAL_RESPONSE),
|
||||||
|
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||||
|
],
|
||||||
|
{**CONFIG_ENTRY_DATA, "serial_number": 0},
|
||||||
|
),
|
||||||
|
# Enters a different hostname that points to the same mac address
|
||||||
|
(
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
|
{
|
||||||
|
**CONFIG_ENTRY_DATA,
|
||||||
|
"host": f"other-{HOST}",
|
||||||
|
},
|
||||||
|
[mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)],
|
||||||
|
CONFIG_ENTRY_DATA, # Updated the host
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
ids=[
|
ids=[
|
||||||
"duplicate-serial-number",
|
"duplicate-mac-unique-id",
|
||||||
|
"duplicate-host-legacy-serial-number",
|
||||||
"duplicate-host-port-no-serial",
|
"duplicate-host-port-no-serial",
|
||||||
|
"duplicate-duplicate-hostname",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_duplicate_config_entries(
|
async def test_duplicate_config_entries(
|
||||||
|
@ -177,6 +231,7 @@ async def test_duplicate_config_entries(
|
||||||
config_entry: MockConfigEntry,
|
config_entry: MockConfigEntry,
|
||||||
responses: list[AiohttpClientMockResponse],
|
responses: list[AiohttpClientMockResponse],
|
||||||
config_flow_responses: list[AiohttpClientMockResponse],
|
config_flow_responses: list[AiohttpClientMockResponse],
|
||||||
|
expected_config_entry_data: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that a device can not be registered twice."""
|
"""Test that a device can not be registered twice."""
|
||||||
await config_entry.async_setup(hass)
|
await config_entry.async_setup(hass)
|
||||||
|
@ -186,8 +241,10 @@ async def test_duplicate_config_entries(
|
||||||
responses.extend(config_flow_responses)
|
responses.extend(config_flow_responses)
|
||||||
|
|
||||||
result = await complete_flow(hass)
|
result = await complete_flow(hass)
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
assert result.get("type") == FlowResultType.ABORT
|
assert result.get("type") == FlowResultType.ABORT
|
||||||
assert result.get("reason") == "already_configured"
|
assert result.get("reason") == "already_configured"
|
||||||
|
assert dict(config_entry.data) == expected_config_entry_data
|
||||||
|
|
||||||
|
|
||||||
async def test_controller_cannot_connect(
|
async def test_controller_cannot_connect(
|
||||||
|
|
|
@ -6,12 +6,21 @@ from http import HTTPStatus
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.rainbird.const import DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.const import CONF_MAC
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
CONFIG_ENTRY_DATA,
|
CONFIG_ENTRY_DATA,
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
MAC_ADDRESS,
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
MODEL_AND_VERSION_RESPONSE,
|
MODEL_AND_VERSION_RESPONSE,
|
||||||
|
SERIAL_NUMBER,
|
||||||
|
WIFI_PARAMS_RESPONSE,
|
||||||
|
mock_json_response,
|
||||||
mock_response,
|
mock_response,
|
||||||
mock_response_error,
|
mock_response_error,
|
||||||
)
|
)
|
||||||
|
@ -20,22 +29,11 @@ from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("config_entry_data", "initial_response"),
|
|
||||||
[
|
|
||||||
(CONFIG_ENTRY_DATA, None),
|
|
||||||
],
|
|
||||||
ids=["config_entry"],
|
|
||||||
)
|
|
||||||
async def test_init_success(
|
async def test_init_success(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: MockConfigEntry,
|
config_entry: MockConfigEntry,
|
||||||
responses: list[AiohttpClientMockResponse],
|
|
||||||
initial_response: AiohttpClientMockResponse | None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test successful setup and unload."""
|
"""Test successful setup and unload."""
|
||||||
if initial_response:
|
|
||||||
responses.insert(0, initial_response)
|
|
||||||
|
|
||||||
await config_entry.async_setup(hass)
|
await config_entry.async_setup(hass)
|
||||||
assert config_entry.state == ConfigEntryState.LOADED
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
@ -88,6 +86,196 @@ async def test_communication_failure(
|
||||||
config_entry_state: list[ConfigEntryState],
|
config_entry_state: list[ConfigEntryState],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test unable to talk to device on startup, which fails setup."""
|
"""Test unable to talk to device on startup, which fails setup."""
|
||||||
|
|
||||||
await config_entry.async_setup(hass)
|
await config_entry.async_setup(hass)
|
||||||
assert config_entry.state == config_entry_state
|
assert config_entry.state == config_entry_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("config_entry_unique_id", "config_entry_data"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{**CONFIG_ENTRY_DATA, "mac": None},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["config_entry"],
|
||||||
|
)
|
||||||
|
async def test_fix_unique_id(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
responses: list[AiohttpClientMockResponse],
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test fix of a config entry with no unique id."""
|
||||||
|
|
||||||
|
responses.insert(0, mock_json_response(WIFI_PARAMS_RESPONSE))
|
||||||
|
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].state == ConfigEntryState.NOT_LOADED
|
||||||
|
assert entries[0].unique_id is None
|
||||||
|
assert entries[0].data.get(CONF_MAC) is None
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
# Verify config entry now has a unique id
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].state == ConfigEntryState.LOADED
|
||||||
|
assert entries[0].unique_id == MAC_ADDRESS_UNIQUE_ID
|
||||||
|
assert entries[0].data.get(CONF_MAC) == MAC_ADDRESS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"config_entry_unique_id",
|
||||||
|
"config_entry_data",
|
||||||
|
"initial_response",
|
||||||
|
"expected_warning",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE),
|
||||||
|
"Unable to fix missing unique id:",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
mock_response_error(HTTPStatus.NOT_FOUND),
|
||||||
|
"Unable to fix missing unique id:",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
mock_response("bogus"),
|
||||||
|
"Unable to fix missing unique id (mac address was None)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["service_unavailable", "not_found", "unexpected_response_format"],
|
||||||
|
)
|
||||||
|
async def test_fix_unique_id_failure(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
initial_response: AiohttpClientMockResponse,
|
||||||
|
responses: list[AiohttpClientMockResponse],
|
||||||
|
expected_warning: str,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test a failure during fix of a config entry with no unique id."""
|
||||||
|
|
||||||
|
responses.insert(0, initial_response)
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
# Config entry is loaded, but not updated
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
assert config_entry.unique_id is None
|
||||||
|
|
||||||
|
assert expected_warning in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("config_entry_unique_id"),
|
||||||
|
[(MAC_ADDRESS_UNIQUE_ID)],
|
||||||
|
)
|
||||||
|
async def test_fix_unique_id_duplicate(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
responses: list[AiohttpClientMockResponse],
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test that a config entry unique id already exists during fix."""
|
||||||
|
# Add a second config entry that has no unique id, but has the same
|
||||||
|
# mac address. When fixing the unique id, it can't use the mac address
|
||||||
|
# since it already exists.
|
||||||
|
other_entry = MockConfigEntry(
|
||||||
|
unique_id=None,
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
)
|
||||||
|
other_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
# Responses for the second config entry. This first fetches wifi params
|
||||||
|
# to repair the unique id.
|
||||||
|
responses_copy = [*responses]
|
||||||
|
responses.append(mock_json_response(WIFI_PARAMS_RESPONSE))
|
||||||
|
responses.extend(responses_copy)
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
assert config_entry.unique_id == MAC_ADDRESS_UNIQUE_ID
|
||||||
|
|
||||||
|
await other_entry.async_setup(hass)
|
||||||
|
# Config entry unique id could not be updated since it already exists
|
||||||
|
assert other_entry.state == ConfigEntryState.SETUP_ERROR
|
||||||
|
|
||||||
|
assert "Unable to fix missing unique id (already exists)" in caplog.text
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"config_entry_unique_id",
|
||||||
|
"serial_number",
|
||||||
|
"entity_unique_id",
|
||||||
|
"expected_unique_id",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(SERIAL_NUMBER, SERIAL_NUMBER, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID),
|
||||||
|
(
|
||||||
|
SERIAL_NUMBER,
|
||||||
|
SERIAL_NUMBER,
|
||||||
|
f"{SERIAL_NUMBER}-rain-delay",
|
||||||
|
f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay",
|
||||||
|
),
|
||||||
|
("0", 0, "0", MAC_ADDRESS_UNIQUE_ID),
|
||||||
|
(
|
||||||
|
"0",
|
||||||
|
0,
|
||||||
|
"0-rain-delay",
|
||||||
|
f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
|
SERIAL_NUMBER,
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MAC_ADDRESS_UNIQUE_ID,
|
||||||
|
SERIAL_NUMBER,
|
||||||
|
f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay",
|
||||||
|
f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=(
|
||||||
|
"serial-number",
|
||||||
|
"serial-number-with-suffix",
|
||||||
|
"zero-serial",
|
||||||
|
"zero-serial-suffix",
|
||||||
|
"new-format",
|
||||||
|
"new-format-suffx",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_fix_entity_unique_ids(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
entity_unique_id: str,
|
||||||
|
expected_unique_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test fixing entity unique ids from old unique id formats."""
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
entity_entry = entity_registry.async_get_or_create(
|
||||||
|
DOMAIN, "number", unique_id=entity_unique_id, config_entry=config_entry
|
||||||
|
)
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
entity_entry = entity_registry.async_get(entity_entry.id)
|
||||||
|
assert entity_entry
|
||||||
|
assert entity_entry.unique_id == expected_unique_id
|
||||||
|
|
|
@ -6,7 +6,7 @@ import pytest
|
||||||
|
|
||||||
from homeassistant.components import number
|
from homeassistant.components import number
|
||||||
from homeassistant.components.rainbird import DOMAIN
|
from homeassistant.components.rainbird import DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
@ -14,15 +14,16 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
ACK_ECHO,
|
ACK_ECHO,
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
MAC_ADDRESS,
|
||||||
RAIN_DELAY,
|
RAIN_DELAY,
|
||||||
RAIN_DELAY_OFF,
|
RAIN_DELAY_OFF,
|
||||||
SERIAL_NUMBER,
|
|
||||||
mock_response,
|
mock_response,
|
||||||
mock_response_error,
|
mock_response_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -66,46 +67,23 @@ async def test_number_values(
|
||||||
|
|
||||||
entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay")
|
entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay")
|
||||||
assert entity_entry
|
assert entity_entry
|
||||||
assert entity_entry.unique_id == "1263613994342-rain-delay"
|
assert entity_entry.unique_id == "4c:a1:61:00:11:22-rain-delay"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("config_entry_unique_id", "entity_unique_id"),
|
|
||||||
[
|
|
||||||
(SERIAL_NUMBER, "1263613994342-rain-delay"),
|
|
||||||
# Some existing config entries may have a "0" serial number but preserve
|
|
||||||
# their unique id
|
|
||||||
(0, "0-rain-delay"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_unique_id(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entity_registry: er.EntityRegistry,
|
|
||||||
entity_unique_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Test number platform."""
|
|
||||||
|
|
||||||
raindelay = hass.states.get("number.rain_bird_controller_rain_delay")
|
|
||||||
assert raindelay is not None
|
|
||||||
assert (
|
|
||||||
raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay"
|
|
||||||
)
|
|
||||||
|
|
||||||
entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay")
|
|
||||||
assert entity_entry
|
|
||||||
assert entity_entry.unique_id == entity_unique_id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_set_value(
|
async def test_set_value(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
responses: list[str],
|
responses: list[str],
|
||||||
config_entry: ConfigEntry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setting the rain delay number."""
|
"""Test setting the rain delay number."""
|
||||||
|
|
||||||
|
raindelay = hass.states.get("number.rain_bird_controller_rain_delay")
|
||||||
|
assert raindelay is not None
|
||||||
|
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)})
|
device = device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, MAC_ADDRESS.lower())}
|
||||||
|
)
|
||||||
assert device
|
assert device
|
||||||
assert device.name == "Rain Bird Controller"
|
assert device.name == "Rain Bird Controller"
|
||||||
assert device.model == "ESP-TM2"
|
assert device.model == "ESP-TM2"
|
||||||
|
@ -138,7 +116,6 @@ async def test_set_value_error(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
responses: list[str],
|
responses: list[str],
|
||||||
config_entry: ConfigEntry,
|
|
||||||
status: HTTPStatus,
|
status: HTTPStatus,
|
||||||
expected_msg: str,
|
expected_msg: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -162,17 +139,25 @@ async def test_set_value_error(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("config_entry_unique_id"),
|
("config_entry_data", "config_entry_unique_id", "setup_config_entry"),
|
||||||
[
|
[
|
||||||
(None),
|
(CONFIG_ENTRY_DATA_OLD_FORMAT, None, None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_no_unique_id(
|
async def test_no_unique_id(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
responses: list[AiohttpClientMockResponse],
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test number platform with no unique id."""
|
"""Test number platform with no unique id."""
|
||||||
|
|
||||||
|
# Failure to migrate config entry to a unique id
|
||||||
|
responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE))
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
raindelay = hass.states.get("number.rain_bird_controller_rain_delay")
|
raindelay = hass.states.get("number.rain_bird_controller_rain_delay")
|
||||||
assert raindelay is not None
|
assert raindelay is not None
|
||||||
assert (
|
assert (
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Tests for rainbird sensor platform."""
|
"""Tests for rainbird sensor platform."""
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
@ -8,9 +9,15 @@ from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF
|
from .conftest import (
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
|
RAIN_DELAY,
|
||||||
|
RAIN_DELAY_OFF,
|
||||||
|
mock_response_error,
|
||||||
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMockResponse
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -49,37 +56,38 @@ async def test_sensors(
|
||||||
|
|
||||||
entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay")
|
entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay")
|
||||||
assert entity_entry
|
assert entity_entry
|
||||||
assert entity_entry.unique_id == "1263613994342-raindelay"
|
assert entity_entry.unique_id == "4c:a1:61:00:11:22-raindelay"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("config_entry_unique_id", "config_entry_data"),
|
("config_entry_unique_id", "config_entry_data", "setup_config_entry"),
|
||||||
[
|
[
|
||||||
# Config entry setup without a unique id since it had no serial number
|
# Config entry setup without a unique id since it had no serial number
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
**CONFIG_ENTRY_DATA,
|
**CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
"serial_number": 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
# Legacy case for old config entries with serial number 0 preserves old behavior
|
|
||||||
(
|
|
||||||
"0",
|
|
||||||
{
|
|
||||||
**CONFIG_ENTRY_DATA,
|
|
||||||
"serial_number": 0,
|
"serial_number": 0,
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_sensor_no_unique_id(
|
async def test_sensor_no_unique_id(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
responses: list[AiohttpClientMockResponse],
|
||||||
config_entry_unique_id: str | None,
|
config_entry_unique_id: str | None,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test sensor platform with no unique id."""
|
"""Test sensor platform with no unique id."""
|
||||||
|
|
||||||
|
# Failure to migrate config entry to a unique id
|
||||||
|
responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE))
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
raindelay = hass.states.get("sensor.rain_bird_controller_raindelay")
|
raindelay = hass.states.get("sensor.rain_bird_controller_raindelay")
|
||||||
assert raindelay is not None
|
assert raindelay is not None
|
||||||
assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay"
|
assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay"
|
||||||
|
|
|
@ -13,12 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
ACK_ECHO,
|
ACK_ECHO,
|
||||||
|
CONFIG_ENTRY_DATA_OLD_FORMAT,
|
||||||
EMPTY_STATIONS_RESPONSE,
|
EMPTY_STATIONS_RESPONSE,
|
||||||
HOST,
|
HOST,
|
||||||
|
MAC_ADDRESS,
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
RAIN_DELAY_OFF,
|
RAIN_DELAY_OFF,
|
||||||
RAIN_SENSOR_OFF,
|
RAIN_SENSOR_OFF,
|
||||||
SERIAL_NUMBER,
|
|
||||||
ZONE_3_ON_RESPONSE,
|
ZONE_3_ON_RESPONSE,
|
||||||
ZONE_5_ON_RESPONSE,
|
ZONE_5_ON_RESPONSE,
|
||||||
ZONE_OFF_RESPONSE,
|
ZONE_OFF_RESPONSE,
|
||||||
|
@ -109,7 +110,7 @@ async def test_zones(
|
||||||
|
|
||||||
# Verify unique id for one of the switches
|
# Verify unique id for one of the switches
|
||||||
entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3")
|
entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3")
|
||||||
assert entity_entry.unique_id == "1263613994342-3"
|
assert entity_entry.unique_id == "4c:a1:61:00:11:22-3"
|
||||||
|
|
||||||
|
|
||||||
async def test_switch_on(
|
async def test_switch_on(
|
||||||
|
@ -226,6 +227,7 @@ async def test_irrigation_service(
|
||||||
"1": "Garden Sprinkler",
|
"1": "Garden Sprinkler",
|
||||||
"2": "Back Yard",
|
"2": "Back Yard",
|
||||||
},
|
},
|
||||||
|
"mac": MAC_ADDRESS,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -274,9 +276,9 @@ async def test_switch_error(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("config_entry_unique_id"),
|
("config_entry_data", "config_entry_unique_id", "setup_config_entry"),
|
||||||
[
|
[
|
||||||
(None),
|
(CONFIG_ENTRY_DATA_OLD_FORMAT, None, None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_no_unique_id(
|
async def test_no_unique_id(
|
||||||
|
@ -284,8 +286,15 @@ async def test_no_unique_id(
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
responses: list[AiohttpClientMockResponse],
|
responses: list[AiohttpClientMockResponse],
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test an irrigation switch with no unique id."""
|
"""Test an irrigation switch with no unique id due to migration failure."""
|
||||||
|
|
||||||
|
# Failure to migrate config entry to a unique id
|
||||||
|
responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE))
|
||||||
|
|
||||||
|
await config_entry.async_setup(hass)
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
zone = hass.states.get("switch.rain_bird_sprinkler_3")
|
zone = hass.states.get("switch.rain_bird_sprinkler_3")
|
||||||
assert zone is not None
|
assert zone is not None
|
||||||
|
@ -294,31 +303,3 @@ async def test_no_unique_id(
|
||||||
|
|
||||||
entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3")
|
entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3")
|
||||||
assert entity_entry is None
|
assert entity_entry is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("config_entry_unique_id", "entity_unique_id"),
|
|
||||||
[
|
|
||||||
(SERIAL_NUMBER, "1263613994342-3"),
|
|
||||||
# Some existing config entries may have a "0" serial number but preserve
|
|
||||||
# their unique id
|
|
||||||
(0, "0-3"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_has_unique_id(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
aioclient_mock: AiohttpClientMocker,
|
|
||||||
responses: list[AiohttpClientMockResponse],
|
|
||||||
entity_registry: er.EntityRegistry,
|
|
||||||
entity_unique_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Test an irrigation switch with no unique id."""
|
|
||||||
|
|
||||||
zone = hass.states.get("switch.rain_bird_sprinkler_3")
|
|
||||||
assert zone is not None
|
|
||||||
assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3"
|
|
||||||
assert zone.state == "off"
|
|
||||||
|
|
||||||
entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3")
|
|
||||||
assert entity_entry
|
|
||||||
assert entity_entry.unique_id == entity_unique_id
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue