mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 08:26:41 +01:00
Add zeroconf support for air-Q (#164727)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,10 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.helpers.selector import BooleanSelector
|
||||
from homeassistant.helpers.service_info.zeroconf import (
|
||||
ATTR_PROPERTIES_ID,
|
||||
ZeroconfServiceInfo,
|
||||
)
|
||||
|
||||
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN
|
||||
|
||||
@@ -46,6 +50,9 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_discovered_host: str
|
||||
_discovered_name: str
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -90,6 +97,58 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery of an air-Q device."""
|
||||
self._discovered_host = discovery_info.host
|
||||
self._discovered_name = discovery_info.properties.get("devicename", "air-Q")
|
||||
device_id = discovery_info.properties.get(ATTR_PROPERTIES_ID)
|
||||
|
||||
if not device_id:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_IP_ADDRESS: self._discovered_host},
|
||||
reload_on_update=True,
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {"name": self._discovered_name}
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user confirmation of a discovered air-Q device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
airq = AirQ(self._discovered_host, user_input[CONF_PASSWORD], session)
|
||||
try:
|
||||
await airq.validate()
|
||||
except ClientConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=self._discovered_name,
|
||||
data={
|
||||
CONF_IP_ADDRESS: self._discovered_host,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={"name": self._discovered_name},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
|
||||
@@ -7,5 +7,13 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.7"]
|
||||
"requirements": ["aioairq==0.4.7"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
"device": "air-q"
|
||||
},
|
||||
"type": "_http._tcp.local."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_input": "[%key:common::config_flow::error::invalid_host%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "Do you want to set up **{name}**?",
|
||||
"title": "Set up air-Q"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
|
||||
6
homeassistant/generated/zeroconf.py
generated
6
homeassistant/generated/zeroconf.py
generated
@@ -565,6 +565,12 @@ ZEROCONF = {
|
||||
},
|
||||
],
|
||||
"_http._tcp.local.": [
|
||||
{
|
||||
"domain": "airq",
|
||||
"properties": {
|
||||
"device": "air-q",
|
||||
},
|
||||
},
|
||||
{
|
||||
"domain": "awair",
|
||||
"name": "awair*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the air-Q config flow."""
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -13,14 +14,25 @@ from homeassistant.components.airq.const import (
|
||||
CONF_RETURN_AVERAGE,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .common import TEST_DEVICE_INFO, TEST_USER_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
|
||||
ip_address=IPv4Address("192.168.0.123"),
|
||||
ip_addresses=[IPv4Address("192.168.0.123")],
|
||||
port=80,
|
||||
hostname="airq.local.",
|
||||
type="_http._tcp.local.",
|
||||
name="air-Q._http._tcp.local.",
|
||||
properties={"device": "air-q", "devicename": "My air-Q", "id": "test-serial-123"},
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
DEFAULT_OPTIONS = {
|
||||
@@ -129,3 +141,142 @@ async def test_options_flow(hass: HomeAssistant, user_input) -> None:
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == entry.options == DEFAULT_OPTIONS | user_input
|
||||
|
||||
|
||||
async def test_zeroconf_discovery(hass: HomeAssistant, mock_airq: AsyncMock) -> None:
|
||||
"""Test zeroconf discovery and successful setup."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=ZEROCONF_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PASSWORD: "password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My air-Q"
|
||||
assert result["result"].unique_id == "test-serial-123"
|
||||
assert result["data"] == {
|
||||
CONF_IP_ADDRESS: "192.168.0.123",
|
||||
CONF_PASSWORD: "password",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(InvalidAuth, "invalid_auth"),
|
||||
(ClientConnectionError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_zeroconf_discovery_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_airq: AsyncMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test zeroconf discovery with invalid password or connection error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=ZEROCONF_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
mock_airq.validate.side_effect = side_effect
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PASSWORD: "wrong_password"},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": expected_error}
|
||||
|
||||
# Recover: correct password on retry
|
||||
mock_airq.validate.side_effect = None
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
{CONF_PASSWORD: "correct_password"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result3["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result3["title"] == "My air-Q"
|
||||
assert result3["data"] == {
|
||||
CONF_IP_ADDRESS: "192.168.0.123",
|
||||
CONF_PASSWORD: "correct_password",
|
||||
}
|
||||
|
||||
|
||||
async def test_zeroconf_discovery_already_configured(
|
||||
hass: HomeAssistant, mock_airq: AsyncMock
|
||||
) -> None:
|
||||
"""Test zeroconf discovery aborts if device is already configured."""
|
||||
MockConfigEntry(
|
||||
data=TEST_USER_DATA,
|
||||
domain=DOMAIN,
|
||||
unique_id="test-serial-123",
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=ZEROCONF_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_zeroconf_updates_ip_on_already_configured(
|
||||
hass: HomeAssistant, mock_airq: AsyncMock
|
||||
) -> None:
|
||||
"""Test zeroconf updates the IP address if device is already configured."""
|
||||
entry = MockConfigEntry(
|
||||
data={CONF_IP_ADDRESS: "192.168.0.1", CONF_PASSWORD: "password"},
|
||||
domain=DOMAIN,
|
||||
unique_id="test-serial-123",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=ZEROCONF_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data[CONF_IP_ADDRESS] == "192.168.0.123"
|
||||
|
||||
|
||||
async def test_zeroconf_discovery_missing_id(
|
||||
hass: HomeAssistant, mock_airq: AsyncMock
|
||||
) -> None:
|
||||
"""Test zeroconf discovery aborts if device ID is missing from properties."""
|
||||
discovery_info = ZeroconfServiceInfo(
|
||||
ip_address=IPv4Address("192.168.0.123"),
|
||||
ip_addresses=[IPv4Address("192.168.0.123")],
|
||||
port=80,
|
||||
hostname="airq.local.",
|
||||
type="_http._tcp.local.",
|
||||
name="air-Q._http._tcp.local.",
|
||||
properties={"device": "air-q", "devicename": "My air-Q"},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=discovery_info,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "incomplete_discovery"
|
||||
|
||||
Reference in New Issue
Block a user