1
0
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:
Renat Sibgatulin
2026-03-05 17:55:34 +01:00
committed by GitHub
parent 9b8432eac3
commit 0923bed4b6
5 changed files with 236 additions and 3 deletions

View File

@@ -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(

View File

@@ -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."
}
]
}

View File

@@ -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%]",

View File

@@ -565,6 +565,12 @@ ZEROCONF = {
},
],
"_http._tcp.local.": [
{
"domain": "airq",
"properties": {
"device": "air-q",
},
},
{
"domain": "awair",
"name": "awair*",

View File

@@ -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"