diff --git a/.coveragerc b/.coveragerc index 7ce0e5ee299246..86e400aab10d7e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -93,6 +93,9 @@ omit = homeassistant/components/azure_devops/const.py homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* + homeassistant/components/baf/__init__.py + homeassistant/components/baf/entity.py + homeassistant/components/baf/fan.py homeassistant/components/baidu/tts.py homeassistant/components/balboa/__init__.py homeassistant/components/beewi_smartclim/sensor.py diff --git a/.strict-typing b/.strict-typing index 8c580dfa1aa3d3..36ed1685e9fef8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -55,6 +55,7 @@ homeassistant.components.aseko_pool_live.* homeassistant.components.asuswrt.* homeassistant.components.automation.* homeassistant.components.backup.* +homeassistant.components.baf.* homeassistant.components.binary_sensor.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* diff --git a/CODEOWNERS b/CODEOWNERS index d07ce028ba93ab..7d8848c76ed32d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -120,6 +120,8 @@ build.json @home-assistant/supervisor /homeassistant/components/azure_service_bus/ @hfurubotten /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core +/homeassistant/components/baf/ @bdraco @jfroy +/tests/components/baf/ @bdraco @jfroy /homeassistant/components/balboa/ @garbled1 /tests/components/balboa/ @garbled1 /homeassistant/components/beewi_smartclim/ @alemuro diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py new file mode 100644 index 00000000000000..4327eb9ddb973c --- /dev/null +++ b/homeassistant/components/baf/__init__.py @@ -0,0 +1,46 @@ +"""The Big Ass Fans integration.""" +from __future__ import annotations + +import asyncio + +from aiobafi6 import Device, Service +from aiobafi6.discovery import PORT + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, QUERY_INTERVAL, RUN_TIMEOUT +from .models import BAFData + +PLATFORMS: list[Platform] = [Platform.FAN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Big Ass Fans from a config entry.""" + ip_address = entry.data[CONF_IP_ADDRESS] + + service = Service(ip_addresses=[ip_address], uuid=entry.unique_id, port=PORT) + device = Device(service, query_interval_seconds=QUERY_INTERVAL) + run_future = device.async_run() + + try: + await asyncio.wait_for(device.async_wait_available(), timeout=RUN_TIMEOUT) + except asyncio.TimeoutError as ex: + run_future.cancel() + raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BAFData(device, run_future) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data: BAFData = hass.data[DOMAIN].pop(entry.entry_id) + data.run_future.cancel() + + return unload_ok diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py new file mode 100644 index 00000000000000..1c2873eb759110 --- /dev/null +++ b/homeassistant/components/baf/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for baf.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from aiobafi6 import Device, Service +from aiobafi6.discovery import PORT +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult +from homeassistant.util.network import is_ipv6_address + +from .const import DOMAIN, RUN_TIMEOUT +from .models import BAFDiscovery + +_LOGGER = logging.getLogger(__name__) + + +async def async_try_connect(ip_address: str) -> Device: + """Validate we can connect to a device.""" + device = Device(Service(ip_addresses=[ip_address], port=PORT)) + run_future = device.async_run() + try: + await asyncio.wait_for(device.async_wait_available(), timeout=RUN_TIMEOUT) + except asyncio.TimeoutError as ex: + raise CannotConnect from ex + finally: + run_future.cancel() + return device + + +class BAFFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle BAF discovery config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the BAF config flow.""" + self.discovery: BAFDiscovery | None = None + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + properties = discovery_info.properties + ip_address = discovery_info.host + if is_ipv6_address(ip_address): + return self.async_abort(reason="ipv6_not_supported") + uuid = properties["uuid"] + model = properties["model"] + name = properties["name"] + await self.async_set_unique_id(uuid, raise_on_progress=False) + self._abort_if_unique_id_configured(updates={CONF_IP_ADDRESS: ip_address}) + self.discovery = BAFDiscovery(ip_address, name, uuid, model) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self.discovery is not None + discovery = self.discovery + if user_input is not None: + return self.async_create_entry( + title=discovery.name, + data={CONF_IP_ADDRESS: discovery.ip_address}, + ) + placeholders = { + "name": discovery.name, + "model": discovery.model, + "ip_address": discovery.ip_address, + } + self.context["title_placeholders"] = placeholders + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + ip_address = (user_input or {}).get(CONF_IP_ADDRESS, "") + if user_input is not None: + try: + device = await async_try_connect(ip_address) + except CannotConnect: + errors[CONF_IP_ADDRESS] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown exception during connection test to %s", ip_address + ) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.dns_sd_uuid) + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: ip_address} + ) + return self.async_create_entry( + title=device.name, + data={CONF_IP_ADDRESS: ip_address}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_IP_ADDRESS, default=ip_address): str} + ), + errors=errors, + ) + + +class CannotConnect(Exception): + """Exception to raise when we cannot connect.""" diff --git a/homeassistant/components/baf/const.py b/homeassistant/components/baf/const.py new file mode 100644 index 00000000000000..9876d7ffec3c17 --- /dev/null +++ b/homeassistant/components/baf/const.py @@ -0,0 +1,19 @@ +"""Constants for the Big Ass Fans integration.""" + +DOMAIN = "baf" + +# Most properties are pushed, only the +# query every 5 minutes so we keep the RPM +# sensors up to date +QUERY_INTERVAL = 300 + +RUN_TIMEOUT = 20 + +PRESET_MODE_AUTO = "Auto" + +SPEED_COUNT = 7 +SPEED_RANGE = (1, SPEED_COUNT) + +ONE_MIN_SECS = 60 +ONE_DAY_SECS = 86400 +HALF_DAY_SECS = 43200 diff --git a/homeassistant/components/baf/entity.py b/homeassistant/components/baf/entity.py new file mode 100644 index 00000000000000..22054d0b16d01a --- /dev/null +++ b/homeassistant/components/baf/entity.py @@ -0,0 +1,48 @@ +"""The baf integration entities.""" +from __future__ import annotations + +from aiobafi6 import Device + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity import DeviceInfo, Entity + + +class BAFEntity(Entity): + """Base class for baf entities.""" + + _attr_should_poll = False + + def __init__(self, device: Device, name: str) -> None: + """Initialize the entity.""" + self._device = device + self._attr_unique_id = format_mac(self._device.mac_address) + self._attr_name = name + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac_address)}, + name=self._device.name, + manufacturer="Big Ass Fans", + model=self._device.model, + sw_version=self._device.firmware_version, + ) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from device.""" + self._attr_available = self._device.available + + @callback + def _async_update_from_device(self, device: Device) -> None: + """Process an update from the device.""" + self._async_update_attrs() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + self._device.add_callback(self._async_update_from_device) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener after this object has been initialized.""" + self._device.remove_callback(self._async_update_from_device) diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py new file mode 100644 index 00000000000000..360926363a57b6 --- /dev/null +++ b/homeassistant/components/baf/fan.py @@ -0,0 +1,97 @@ +"""Support for Big Ass Fans fan.""" +from __future__ import annotations + +import math +from typing import Any + +from aiobafi6 import OffOnAuto + +from homeassistant import config_entries +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import DOMAIN, PRESET_MODE_AUTO, SPEED_COUNT, SPEED_RANGE +from .entity import BAFEntity +from .models import BAFData + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SenseME fans.""" + data: BAFData = hass.data[DOMAIN][entry.entry_id] + if data.device.has_fan: + async_add_entities([BAFFan(data.device, data.device.name)]) + + +class BAFFan(BAFEntity, FanEntity): + """BAF ceiling fan component.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + _attr_preset_modes = [PRESET_MODE_AUTO] + _attr_speed_count = SPEED_COUNT + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from device.""" + self._attr_is_on = self._device.fan_mode == OffOnAuto.ON + self._attr_current_direction = DIRECTION_FORWARD + if self._device.reverse_enable: + self._attr_current_direction = DIRECTION_REVERSE + if self._device.speed is not None: + self._attr_percentage = ranged_value_to_percentage( + SPEED_RANGE, self._device.speed + ) + else: + self._attr_percentage = None + auto = self._device.fan_mode == OffOnAuto.AUTO + self._attr_preset_mode = PRESET_MODE_AUTO if auto else None + super()._async_update_attrs() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + device = self._device + if device.fan_mode != OffOnAuto.ON: + device.fan_mode = OffOnAuto.ON + device.speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on with a percentage or preset mode.""" + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + return + if percentage is None: + self._device.fan_mode = OffOnAuto.ON + return + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self._device.fan_mode = OffOnAuto.OFF + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if preset_mode != PRESET_MODE_AUTO: + raise ValueError(f"Invalid preset mode: {preset_mode}") + self._device.fan_mode = OffOnAuto.AUTO + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._device.reverse_enable = direction == DIRECTION_REVERSE diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json new file mode 100644 index 00000000000000..9dfc35685e324a --- /dev/null +++ b/homeassistant/components/baf/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "baf", + "name": "Big Ass Fans", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/baf", + "requirements": ["aiobafi6==0.3.0"], + "codeowners": ["@bdraco", "@jfroy"], + "iot_class": "local_push", + "zeroconf": [ + { "type": "_api._tcp.local.", "properties": { "model": "haiku*" } }, + { "type": "_api._tcp.local.", "properties": { "model": "i6*" } } + ] +} diff --git a/homeassistant/components/baf/models.py b/homeassistant/components/baf/models.py new file mode 100644 index 00000000000000..de5c4a3498b576 --- /dev/null +++ b/homeassistant/components/baf/models.py @@ -0,0 +1,25 @@ +"""The baf integration models.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from aiobafi6 import Device + + +@dataclass +class BAFData: + """Data for the baf integration.""" + + device: Device + run_future: asyncio.Future + + +@dataclass +class BAFDiscovery: + """A BAF Discovery.""" + + ip_address: str + name: str + uuid: str + model: str diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json new file mode 100644 index 00000000000000..a26e3152326050 --- /dev/null +++ b/homeassistant/components/baf/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "{name} - {model} ({ip_address})", + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name} - {model} ({ip_address})?" + } + }, + "abort": { + "ipv6_not_supported": "IPv6 is not supported.", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/baf/translations/en.json b/homeassistant/components/baf/translations/en.json new file mode 100644 index 00000000000000..4bb7256a69260f --- /dev/null +++ b/homeassistant/components/baf/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "ipv6_not_supported": "IPv6 is not supported." + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{name} - {model} ({ip_address})", + "step": { + "discovery_confirm": { + "description": "Do you want to setup {name} - {model} ({ip_address})?" + }, + "user": { + "data": { + "ip_address": "IP Address" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ae2ab6339aa0d2..fc96f60af49606 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -42,6 +42,7 @@ "axis", "azure_devops", "azure_event_hub", + "baf", "balboa", "blebox", "blink", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index d1a15356ddc2e6..415e2746c6d515 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -42,6 +42,20 @@ "domain": "apple_tv" } ], + "_api._tcp.local.": [ + { + "domain": "baf", + "properties": { + "model": "haiku*" + } + }, + { + "domain": "baf", + "properties": { + "model": "i6*" + } + } + ], "_api._udp.local.": [ { "domain": "guardian" diff --git a/mypy.ini b/mypy.ini index 532c45263729ba..7784acd9fe61bd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -368,6 +368,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.baf.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.binary_sensor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2e8c89e5797ce8..9b1da955d18a22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -121,6 +121,9 @@ aioasuswrt==1.4.0 # homeassistant.components.azure_devops aioazuredevops==1.3.5 +# homeassistant.components.baf +aiobafi6==0.3.0 + # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30510f0891064e..d5bde5d8337b12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -108,6 +108,9 @@ aioasuswrt==1.4.0 # homeassistant.components.azure_devops aioazuredevops==1.3.5 +# homeassistant.components.baf +aiobafi6==0.3.0 + # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/tests/components/baf/__init__.py b/tests/components/baf/__init__.py new file mode 100644 index 00000000000000..e0432ed643a0fc --- /dev/null +++ b/tests/components/baf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Big Ass Fans integration.""" diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py new file mode 100644 index 00000000000000..687a871ed4c681 --- /dev/null +++ b/tests/components/baf/test_config_flow.py @@ -0,0 +1,158 @@ +"""Test the baf config flow.""" +import asyncio +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.baf.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form_user(hass): + """Test we get the user form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch("homeassistant.components.baf.config_flow.Device.async_run",), patch( + "homeassistant.components.baf.config_flow.Device.async_wait_available", + ), patch( + "homeassistant.components.baf.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: "127.0.0.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "127.0.0.1" + assert result2["data"] == {CONF_IP_ADDRESS: "127.0.0.1"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.baf.config_flow.Device.async_run",), patch( + "homeassistant.components.baf.config_flow.Device.async_wait_available", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: "127.0.0.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.baf.config_flow.Device.async_run",), patch( + "homeassistant.components.baf.config_flow.Device.async_wait_available", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: "127.0.0.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_zeroconf_discovery(hass): + """Test we can setup from zeroconf discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + addresses=["127.0.0.1"], + hostname="mock_hostname", + name="testfan", + port=None, + properties={"name": "My Fan", "model": "Haiku", "uuid": "1234"}, + type="mock_type", + ), + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.baf.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "My Fan" + assert result2["data"] == {CONF_IP_ADDRESS: "127.0.0.1"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_updates_existing_ip(hass): + """Test we can setup from zeroconf discovery.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_IP_ADDRESS: "127.0.0.2"}, unique_id="1234" + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + addresses=["127.0.0.1"], + hostname="mock_hostname", + name="testfan", + port=None, + properties={"name": "My Fan", "model": "Haiku", "uuid": "1234"}, + type="mock_type", + ), + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + + +async def test_zeroconf_rejects_ipv6(hass): + """Test zeroconf discovery rejects ipv6.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="fd00::b27c:63bb:cc85:4ea0", + addresses=["fd00::b27c:63bb:cc85:4ea0"], + hostname="mock_hostname", + name="testfan", + port=None, + properties={"name": "My Fan", "model": "Haiku", "uuid": "1234"}, + type="mock_type", + ), + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "ipv6_not_supported"