Compare commits

..

2 Commits

Author SHA1 Message Date
mib1185
6608db6098 fix test case naming 2026-02-24 23:10:48 +00:00
mib1185
bfce7a6893 add number.changed trigger 2026-02-24 23:00:13 +00:00
112 changed files with 1082 additions and 6527 deletions

View File

@@ -272,7 +272,7 @@ jobs:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }}
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
@@ -294,21 +294,6 @@ jobs:
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -336,9 +321,8 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
uses: home-assistant/builder@2025.11.0 # zizmor: ignore[unpinned-uses]
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--target /data/machine \

1
CODEOWNERS generated
View File

@@ -1966,7 +1966,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/zone/ @home-assistant/core
/tests/components/zone/ @home-assistant/core
/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
/tests/components/zoneminder/ @rohankapoorcom @nabbi
/homeassistant/components/zwave_js/ @home-assistant/z-wave
/tests/components/zwave_js/ @home-assistant/z-wave
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS

View File

@@ -4,16 +4,7 @@ from __future__ import annotations
import logging
from airos.airos6 import AirOS6
from airos.airos8 import AirOS8
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
from homeassistant.const import (
CONF_HOST,
@@ -24,11 +15,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -53,40 +39,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
)
conn_data = {
CONF_HOST: entry.data[CONF_HOST],
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
"session": session,
}
# Determine firmware version before creating the device instance
try:
device_data: DetectDeviceData = await async_get_firmware_data(**conn_data)
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
raise ConfigEntryNotReady from err
except (
AirOSConnectionAuthenticationError,
AirOSDataMissingError,
) as err:
raise ConfigEntryAuthFailed from err
except AirOSKeyDataMissingError as err:
raise ConfigEntryError("key_data_missing") from err
except Exception as err:
raise ConfigEntryError("unknown") from err
airos_class: type[AirOS8 | AirOS6] = (
AirOS8 if device_data["fw_major"] == 8 else AirOS6
airos_device = AirOS8(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
airos_device = airos_class(**conn_data)
coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device)
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -4,9 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar
from airos.data import AirOSDataBaseClass
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -20,24 +18,25 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription(
BinarySensorEntityDescription,
Generic[AirOSDataModel],
):
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe an AirOS binary sensor."""
value_fn: Callable[[AirOSDataModel], bool]
value_fn: Callable[[AirOS8Data], bool]
AirOS8BinarySensorEntityDescription = AirOSBinarySensorEntityDescription[AirOS8Data]
COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
AirOSBinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOSBinarySensorEntityDescription(
key="dhcp_client",
translation_key="dhcp_client",
@@ -54,23 +53,6 @@ COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="pppoe",
translation_key="pppoe",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.pppoe,
entity_registry_enabled_default=False,
),
)
AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
AirOS8BinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOS8BinarySensorEntityDescription(
key="dhcp6_server",
translation_key="dhcp6_server",
device_class=BinarySensorDeviceClass.RUNNING,
@@ -78,6 +60,14 @@ AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
value_fn=lambda data: data.services.dhcp6d_stateful,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="pppoe",
translation_key="pppoe",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.pppoe,
entity_registry_enabled_default=False,
),
)
@@ -89,20 +79,10 @@ async def async_setup_entry(
"""Set up the AirOS binary sensors from a config entry."""
coordinator = config_entry.runtime_data
entities: list[BinarySensorEntity] = []
entities.extend(
AirOSBinarySensor(coordinator, description)
for description in COMMON_BINARY_SENSORS
async_add_entities(
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
)
if coordinator.device_data["fw_major"] == 8:
entities.extend(
AirOSBinarySensor(coordinator, description)
for description in AIROS8_BINARY_SENSORS
)
async_add_entities(entities)
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
"""Representation of a binary sensor."""

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import logging
from airos.exceptions import AirOSException
from homeassistant.components.button import (
@@ -16,6 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
REBOOT_BUTTON = ButtonEntityDescription(

View File

@@ -7,8 +7,6 @@ from collections.abc import Mapping
import logging
from typing import Any
from airos.airos6 import AirOS6
from airos.airos8 import AirOS8
from airos.discovery import airos_discover_devices
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -19,7 +17,6 @@ from airos.exceptions import (
AirOSKeyDataMissingError,
AirOSListenerError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
import voluptuous as vol
from homeassistant.config_entries import (
@@ -56,11 +53,10 @@ from .const import (
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import AirOS8
_LOGGER = logging.getLogger(__name__)
AirOSDeviceDetect = AirOS8 | AirOS6
# Discovery duration in seconds, airOS announces every 20 seconds
DISCOVER_INTERVAL: int = 30
@@ -96,7 +92,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOSDeviceDetect
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
self.discovered_devices: dict[str, dict[str, Any]] = {}
self.discovery_abort_reason: str | None = None
@@ -139,14 +135,16 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
)
airos_device = AirOS8(
host=config_data[CONF_HOST],
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
try:
device_data: DetectDeviceData = await async_get_firmware_data(
host=config_data[CONF_HOST],
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
await airos_device.login()
airos_data = await airos_device.status()
except (
AirOSConnectionSetupError,
@@ -161,14 +159,14 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception during credential validation")
self.errors["base"] = "unknown"
else:
await self.async_set_unique_id(device_data["mac"])
await self.async_set_unique_id(airos_data.derived.mac)
if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
return {"title": device_data["hostname"], "data": config_data}
return {"title": airos_data.host.hostname, "data": config_data}
return None

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import logging
from airos.airos6 import AirOS6, AirOS6Data
from airos.airos8 import AirOS8, AirOS8Data
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -12,7 +11,6 @@ from airos.exceptions import (
AirOSDataMissingError,
AirOSDeviceConnectionError,
)
from airos.helpers import DetectDeviceData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -23,28 +21,19 @@ from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
AirOSDeviceDetect = AirOS8 | AirOS6
AirOSDataDetect = AirOS8Data | AirOS6Data
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
"""Class to manage fetching AirOS data from single endpoint."""
airos_device: AirOSDeviceDetect
config_entry: AirOSConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
device_data: DetectDeviceData,
airos_device: AirOSDeviceDetect,
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
self.device_data = device_data
super().__init__(
hass,
_LOGGER,
@@ -53,7 +42,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> AirOSDataDetect:
async def _async_update_data(self) -> AirOS8Data:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
@@ -73,7 +62,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except AirOSDataMissingError as err:
except (AirOSDataMissingError,) as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["airos==0.6.4"]
}

View File

@@ -42,20 +42,16 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery:
status: exempt
comment: No way to detect device on the network
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -65,10 +61,8 @@ rules:
status: exempt
comment: no (custom) icons used or envisioned
reconfiguration-flow: done
repair-issues: done
stale-devices:
status: exempt
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done

View File

@@ -5,14 +5,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Generic, TypeVar
from airos.data import (
AirOSDataBaseClass,
DerivedWirelessMode,
DerivedWirelessRole,
NetRole,
)
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -43,19 +37,15 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
class AirOSSensorEntityDescription(SensorEntityDescription):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOSDataModel], StateType]
value_fn: Callable[[AirOS8Data], StateType]
AirOS8SensorEntityDescription = AirOSSensorEntityDescription[AirOS8Data]
COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
AirOSSensorEntityDescription(
key="host_cpuload",
translation_key="host_cpuload",
@@ -85,6 +75,54 @@ COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.antenna_gain,
),
AirOSSensorEntityDescription(
key="wireless_throughput_tx",
translation_key="wireless_throughput_tx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOSSensorEntityDescription(
key="wireless_throughput_rx",
translation_key="wireless_throughput_rx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.rx,
),
AirOSSensorEntityDescription(
key="wireless_polling_dl_capacity",
translation_key="wireless_polling_dl_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
key="wireless_polling_ul_capacity",
translation_key="wireless_polling_ul_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
AirOSSensorEntityDescription(
key="host_uptime",
translation_key="host_uptime",
@@ -120,57 +158,6 @@ COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
options=WIRELESS_ROLE_OPTIONS,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.antenna_gain,
),
AirOSSensorEntityDescription(
key="wireless_polling_dl_capacity",
translation_key="wireless_polling_dl_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
key="wireless_polling_ul_capacity",
translation_key="wireless_polling_ul_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
)
AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = (
AirOS8SensorEntityDescription(
key="wireless_throughput_tx",
translation_key="wireless_throughput_tx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOS8SensorEntityDescription(
key="wireless_throughput_rx",
translation_key="wireless_throughput_rx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.rx,
),
)
@@ -182,14 +169,7 @@ async def async_setup_entry(
"""Set up the AirOS sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
AirOSSensor(coordinator, description) for description in COMMON_SENSORS
)
if coordinator.device_data["fw_major"] == 8:
async_add_entities(
AirOSSensor(coordinator, description) for description in AIROS8_SENSORS
)
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
class AirOSSensor(AirOSEntity, SensorEntity):

View File

@@ -148,6 +148,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"number",
"person",
"scene",
"siren",

View File

@@ -2,17 +2,10 @@
from datetime import timedelta
from pyecobee import (
ECOBEE_API_KEY,
ECOBEE_PASSWORD,
ECOBEE_REFRESH_TOKEN,
ECOBEE_USERNAME,
Ecobee,
ExpiredTokenError,
)
from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.util import Throttle
@@ -25,19 +18,10 @@ type EcobeeConfigEntry = ConfigEntry[EcobeeData]
async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
"""Set up ecobee via a config entry."""
api_key = entry.data.get(CONF_API_KEY)
username = entry.data.get(CONF_USERNAME)
password = entry.data.get(CONF_PASSWORD)
api_key = entry.data[CONF_API_KEY]
refresh_token = entry.data[CONF_REFRESH_TOKEN]
runtime_data = EcobeeData(
hass,
entry,
api_key=api_key,
username=username,
password=password,
refresh_token=refresh_token,
)
runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
if not await runtime_data.refresh():
return False
@@ -62,32 +46,14 @@ class EcobeeData:
"""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
api_key: str | None = None,
username: str | None = None,
password: str | None = None,
refresh_token: str | None = None,
self, hass: HomeAssistant, entry: ConfigEntry, api_key: str, refresh_token: str
) -> None:
"""Initialize the Ecobee data object."""
self._hass = hass
self.entry = entry
if api_key:
self.ecobee = Ecobee(
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
)
elif username and password:
self.ecobee = Ecobee(
config={
ECOBEE_USERNAME: username,
ECOBEE_PASSWORD: password,
ECOBEE_REFRESH_TOKEN: refresh_token,
}
)
else:
raise ValueError("No ecobee credentials provided")
self.ecobee = Ecobee(
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update(self):
@@ -103,23 +69,12 @@ class EcobeeData:
"""Refresh ecobee tokens and update config entry."""
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
data = {}
if self.ecobee.config.get(ECOBEE_API_KEY):
data = {
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
}
elif self.ecobee.config.get(ECOBEE_USERNAME) and self.ecobee.config.get(
ECOBEE_PASSWORD
):
data = {
CONF_USERNAME: self.ecobee.config[ECOBEE_USERNAME],
CONF_PASSWORD: self.ecobee.config[ECOBEE_PASSWORD],
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
}
self._hass.config_entries.async_update_entry(
self.entry,
data=data,
data={
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
},
)
return True
_LOGGER.error("Error refreshing ecobee tokens")

View File

@@ -2,21 +2,15 @@
from typing import Any
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
from pyecobee import ECOBEE_API_KEY, Ecobee
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_API_KEY
from .const import CONF_REFRESH_TOKEN, DOMAIN
_USER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_API_KEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
)
_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -33,34 +27,13 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
api_key = user_input.get(CONF_API_KEY)
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]})
if api_key and not (username or password):
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
# We have a PIN; move to the next step of the flow.
return await self.async_step_authorize()
errors["base"] = "pin_request_failed"
elif username and password and not api_key:
self._ecobee = Ecobee(
config={
ECOBEE_USERNAME: username,
ECOBEE_PASSWORD: password,
}
)
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
config = {
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
}
return self.async_create_entry(title=DOMAIN, data=config)
errors["base"] = "login_failed"
else:
errors["base"] = "invalid_auth"
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
# We have a PIN; move to the next step of the flow.
return await self.async_step_authorize()
errors["base"] = "pin_request_failed"
return self.async_show_form(
step_id="user",

View File

@@ -4,8 +4,6 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
"token_request_failed": "Error requesting tokens from ecobee; please try again."
},

View File

@@ -55,7 +55,7 @@ async def async_setup_entry(
entities: list[HomematicipGenericEntity] = []
entities.extend(
HomematicipColorLight(hap, d, ch.index)
HomematicipLightHS(hap, d, ch.index)
for d in hap.home.devices
for ch in d.functionalChannels
if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL
@@ -136,32 +136,16 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
await self._device.turn_off_async()
class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP color light."""
class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP light with HS color mode."""
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
"""Initialize the light entity."""
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
def _supports_color(self) -> bool:
"""Return true if device supports hue/saturation color control."""
channel = self.get_channel_or_raise()
return channel.hue is not None and channel.saturationLevel is not None
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._supports_color():
return ColorMode.HS
return ColorMode.BRIGHTNESS
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Return the supported color modes."""
if self._supports_color():
return {ColorMode.HS}
return {ColorMode.BRIGHTNESS}
@property
def is_on(self) -> bool:
"""Return true if light is on."""
@@ -188,26 +172,18 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
channel = self.get_channel_or_raise()
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
if ATTR_HS_COLOR not in kwargs:
hue = channel.hue
saturation = channel.saturationLevel
if ATTR_BRIGHTNESS not in kwargs:
# If no brightness is set, use the current brightness
dim_level = channel.dimLevel or 1.0
# Use dim-only method for monochrome mode (hue/saturation not supported)
if not self._supports_color():
await channel.set_dim_level_async(dim_level=dim_level)
return
# Full color mode with hue/saturation
if ATTR_HS_COLOR in kwargs:
hs_color = kwargs[ATTR_HS_COLOR]
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
else:
hue = channel.hue
saturation = channel.saturationLevel
await channel.set_hue_saturation_dim_level_async(
hue=hue, saturation_level=saturation, dim_level=dim_level
)

View File

@@ -3,8 +3,6 @@
from __future__ import annotations
import asyncio
from datetime import datetime
import logging
from pyliebherrhomeapi import LiebherrClient
from pyliebherrhomeapi.exceptions import (
@@ -16,13 +14,8 @@ from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from .const import DEVICE_SCAN_INTERVAL, DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
_LOGGER = logging.getLogger(__name__)
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
PLATFORMS: list[Platform] = [
Platform.NUMBER,
@@ -49,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
# Create a coordinator for each device (may be empty if no devices)
data = LiebherrData(client=client)
coordinators: dict[str, LiebherrCoordinator] = {}
for device in devices:
coordinator = LiebherrCoordinator(
hass=hass,
@@ -57,61 +50,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
client=client,
device_id=device.device_id,
)
data.coordinators[device.device_id] = coordinator
coordinators[device.device_id] = coordinator
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in data.coordinators.values()
for coordinator in coordinators.values()
)
)
# Store runtime data
entry.runtime_data = data
# Store coordinators in runtime data
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Schedule periodic scan for new devices
async def _async_scan_for_new_devices(_now: datetime) -> None:
"""Scan for new devices added to the account."""
try:
devices = await client.get_devices()
except LiebherrAuthenticationError, LiebherrConnectionError:
_LOGGER.debug("Failed to scan for new devices")
return
except Exception:
_LOGGER.exception("Unexpected error scanning for new devices")
return
new_coordinators: list[LiebherrCoordinator] = []
for device in devices:
if device.device_id not in data.coordinators:
coordinator = LiebherrCoordinator(
hass=hass,
config_entry=entry,
client=client,
device_id=device.device_id,
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
_LOGGER.debug("Failed to set up new device %s", device.device_id)
continue
data.coordinators[device.device_id] = coordinator
new_coordinators.append(coordinator)
if new_coordinators:
async_dispatcher_send(
hass,
f"{DOMAIN}_new_device_{entry.entry_id}",
new_coordinators,
)
entry.async_on_unload(
async_track_time_interval(
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
)
)
return True

View File

@@ -6,6 +6,4 @@ from typing import Final
DOMAIN: Final = "liebherr"
MANUFACTURER: Final = "Liebherr"
SCAN_INTERVAL: Final = timedelta(seconds=60)
DEVICE_SCAN_INTERVAL: Final = timedelta(minutes=5)
REFRESH_DELAY: Final = timedelta(seconds=5)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from pyliebherrhomeapi import (
@@ -18,20 +18,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
from .const import DOMAIN
type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]]
_LOGGER = logging.getLogger(__name__)
@dataclass
class LiebherrData:
"""Runtime data for the Liebherr integration."""
client: LiebherrClient
coordinators: dict[str, LiebherrCoordinator] = field(default_factory=dict)
type LiebherrConfigEntry = ConfigEntry[LiebherrData]
SCAN_INTERVAL = timedelta(seconds=60)
class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):

View File

@@ -29,6 +29,6 @@ async def async_get_config_entry_diagnostics(
},
"data": asdict(coordinator.data),
}
for device_id, coordinator in entry.runtime_data.coordinators.items()
for device_id, coordinator in entry.runtime_data.items()
},
}

View File

@@ -16,11 +16,9 @@ from homeassistant.components.number import (
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
@@ -55,41 +53,22 @@ NUMBER_TYPES: tuple[LiebherrNumberEntityDescription, ...] = (
)
def _create_number_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrNumber]:
"""Create number entities for the given coordinators."""
return [
LiebherrNumber(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators
for temp_control in coordinator.data.get_temperature_controls().values()
for description in NUMBER_TYPES
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr number entities."""
coordinators = entry.runtime_data
async_add_entities(
_create_number_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add number entities for new devices."""
async_add_entities(_create_number_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
LiebherrNumber(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators.values()
for temp_control in coordinator.data.get_temperature_controls().values()
for description in NUMBER_TYPES
)

View File

@@ -53,7 +53,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:

View File

@@ -18,11 +18,9 @@ from pyliebherrhomeapi import (
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import ZONE_POSITION_MAP, LiebherrEntity
@@ -111,13 +109,15 @@ SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
]
def _create_select_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrSelectEntity]:
"""Create select entities for the given coordinators."""
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr select entities."""
entities: list[LiebherrSelectEntity] = []
for coordinator in coordinators:
for coordinator in entry.runtime_data.values():
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
for control in coordinator.data.controls:
@@ -137,29 +137,7 @@ def _create_select_entities(
)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr select entities."""
async_add_entities(
_create_select_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add select entities for new devices."""
async_add_entities(_create_select_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
)
async_add_entities(entities)
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):

View File

@@ -14,12 +14,10 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
@@ -50,41 +48,22 @@ SENSOR_TYPES: tuple[LiebherrSensorEntityDescription, ...] = (
)
def _create_sensor_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrSensor]:
"""Create sensor entities for the given coordinators."""
return [
LiebherrSensor(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators
for temp_control in coordinator.data.get_temperature_controls().values()
for description in SENSOR_TYPES
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr sensor entities."""
coordinators = entry.runtime_data
async_add_entities(
_create_sensor_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add sensor entities for new devices."""
async_add_entities(_create_sensor_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
LiebherrSensor(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators.values()
for temp_control in coordinator.data.get_temperature_controls().values()
for description in SENSOR_TYPES
)

View File

@@ -15,11 +15,9 @@ from pyliebherrhomeapi.const import (
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import ZONE_POSITION_MAP, LiebherrEntity
@@ -92,13 +90,15 @@ DEVICE_SWITCH_TYPES: dict[str, LiebherrDeviceSwitchEntityDescription] = {
}
def _create_switch_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrDeviceSwitch | LiebherrZoneSwitch]:
"""Create switch entities for the given coordinators."""
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr switch entities."""
entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = []
for coordinator in coordinators:
for coordinator in entry.runtime_data.values():
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
for control in coordinator.data.controls:
@@ -127,29 +127,7 @@ def _create_switch_entities(
)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr switch entities."""
async_add_entities(
_create_switch_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add switch entities for new devices."""
async_add_entities(_create_switch_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
)
async_add_entities(entities)
class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):

View File

@@ -213,8 +213,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=user_input.get(CONF_USERNAME),
password=user_input.get(CONF_PASSWORD),
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_configured()
@@ -222,8 +222,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
title=info["title"],
data={
CONF_HOST: self._pending_host,
CONF_USERNAME: user_input.get(CONF_USERNAME),
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
@@ -253,8 +253,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
if info := await self._async_validate_credentials(
reauth_entry.data[CONF_HOST],
errors,
username=user_input.get(CONF_USERNAME),
password=user_input.get(CONF_PASSWORD),
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
@@ -318,13 +318,11 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=username,
password=password,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
@@ -332,8 +330,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry,
data_updates={
CONF_HOST: self._pending_host,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/nrgkick",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["nrgkick-api==1.7.1"],
"zeroconf": ["_nrgkick._tcp.local."]
}

View File

@@ -41,7 +41,7 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -173,5 +173,10 @@
"set_value": {
"service": "mdi:numeric"
}
},
"triggers": {
"changed": {
"trigger": "mdi:counter"
}
}
}

View File

@@ -204,5 +204,11 @@
"name": "Set"
}
},
"title": "Number"
"title": "Number",
"triggers": {
"changed": {
"description": "Triggers when a number value changes.",
"name": "Number changed"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for number entities."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(DOMAIN),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for number entities."""
return TRIGGERS

View File

@@ -0,0 +1,4 @@
changed:
target:
entity:
domain: number

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["powerfox==2.1.1"],
"requirements": ["powerfox==2.1.0"],
"zeroconf": [
{
"name": "powerfox*",

View File

@@ -2,18 +2,12 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError, PowerfoxLocal
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -27,12 +21,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Powerfox Local."""
@@ -45,7 +33,7 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
errors = {}
errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST]
@@ -59,15 +47,7 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
except PowerfoxConnectionError:
errors["base"] = "cannot_connect"
else:
if self.source == SOURCE_USER:
return self._async_create_entry()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
CONF_HOST: self._host,
CONF_API_KEY: self._api_key,
},
)
return self._async_create_entry()
return self.async_show_form(
step_id="user",
@@ -104,51 +84,6 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a confirmation flow for zeroconf discovery."""
return self._async_create_entry()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication flow."""
self._host = entry_data[CONF_HOST]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication confirmation."""
errors = {}
if user_input is not None:
self._api_key = user_input[CONF_API_KEY]
reauth_entry = self._get_reauth_entry()
client = PowerfoxLocal(
host=reauth_entry.data[CONF_HOST],
api_key=user_input[CONF_API_KEY],
session=async_get_clientsession(self.hass),
)
try:
await client.value()
except PowerfoxAuthenticationError:
errors["base"] = "invalid_auth"
except PowerfoxConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user()
def _async_create_entry(self) -> ConfigFlowResult:
"""Create a config entry."""
return self.async_create_entry(
@@ -168,8 +103,5 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
)
await client.value()
await self.async_set_unique_id(self._device_id, raise_on_progress=False)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
await self.async_set_unique_id(self._device_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})

View File

@@ -2,17 +2,11 @@
from __future__ import annotations
from powerfox import (
LocalResponse,
PowerfoxAuthenticationError,
PowerfoxConnectionError,
PowerfoxLocal,
)
from powerfox import LocalResponse, PowerfoxConnectionError, PowerfoxLocal
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -46,12 +40,6 @@ class PowerfoxLocalDataUpdateCoordinator(DataUpdateCoordinator[LocalResponse]):
"""Fetch data from the local poweropti."""
try:
return await self.client.value()
except PowerfoxAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": str(err)},
) from err
except PowerfoxConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -1,24 +0,0 @@
"""Support for Powerfox Local diagnostics."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import PowerfoxLocalConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PowerfoxLocalConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Powerfox Local config entry."""
coordinator = entry.runtime_data
return {
"power": coordinator.data.power,
"energy_usage": coordinator.data.energy_usage,
"energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff,
"energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff,
"energy_return": coordinator.data.energy_return,
}

View File

@@ -6,8 +6,8 @@
"documentation": "https://www.home-assistant.io/integrations/powerfox_local",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["powerfox==2.1.1"],
"quality_scale": "bronze",
"requirements": ["powerfox==2.1.0"],
"zeroconf": [
{
"name": "powerfox*",

View File

@@ -43,12 +43,12 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: done
@@ -74,7 +74,7 @@ rules:
status: exempt
comment: |
There is no need for icon translations.
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |

View File

@@ -2,26 +2,13 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::powerfox_local::config::step::user::data_description::api_key%]"
},
"description": "The API key for your Poweropti device is no longer valid.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
@@ -56,9 +43,6 @@
}
},
"exceptions": {
"invalid_auth": {
"message": "Error while authenticating with the device: {error}"
},
"update_failed": {
"message": "Error while updating the device: {error}"
}

View File

@@ -74,20 +74,16 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
raise ProxmoxSSLError from err
except ConnectTimeout as err:
raise ProxmoxConnectTimeout from err
except ResourceException as err:
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise ProxmoxNoNodesFound from err
except requests.exceptions.ConnectionError as err:
raise ProxmoxConnectionError from err
nodes_data: list[dict[str, Any]] = []
for node in nodes:
try:
vms = client.nodes(node["node"]).qemu.get()
containers = client.nodes(node["node"]).lxc.get()
except ResourceException as err:
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise ProxmoxNoNodesFound from err
except requests.exceptions.ConnectionError as err:
raise ProxmoxConnectionError from err
nodes_data.append(
{
@@ -201,30 +197,18 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
"""Validate the user input. Return nodes data and/or errors."""
errors: dict[str, str] = {}
proxmox_nodes: list[dict[str, Any]] = []
err: ProxmoxError | None = None
try:
proxmox_nodes = await self.hass.async_add_executor_job(
_get_nodes_data, user_input
)
except ProxmoxConnectTimeout as exc:
except ProxmoxConnectTimeout:
errors["base"] = "connect_timeout"
err = exc
except ProxmoxAuthenticationError as exc:
except ProxmoxAuthenticationError:
errors["base"] = "invalid_auth"
err = exc
except ProxmoxSSLError as exc:
except ProxmoxSSLError:
errors["base"] = "ssl_error"
err = exc
except ProxmoxNoNodesFound as exc:
except ProxmoxNoNodesFound:
errors["base"] = "no_nodes_found"
err = exc
except ProxmoxConnectionError as exc:
errors["base"] = "cannot_connect"
err = exc
if err is not None:
_LOGGER.debug("Error: %s: %s", errors["base"], err)
return proxmox_nodes, errors
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
@@ -243,8 +227,6 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="ssl_error")
except ProxmoxNoNodesFound:
return self.async_abort(reason="no_nodes_found")
except ProxmoxConnectionError:
return self.async_abort(reason="cannot_connect")
return self.async_create_entry(
title=import_data[CONF_HOST],
@@ -252,25 +234,17 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
)
class ProxmoxError(HomeAssistantError):
"""Base class for Proxmox VE errors."""
class ProxmoxNoNodesFound(ProxmoxError):
class ProxmoxNoNodesFound(HomeAssistantError):
"""Error to indicate no nodes found."""
class ProxmoxConnectTimeout(ProxmoxError):
class ProxmoxConnectTimeout(HomeAssistantError):
"""Error to indicate a connection timeout."""
class ProxmoxSSLError(ProxmoxError):
class ProxmoxSSLError(HomeAssistantError):
"""Error to indicate an SSL error."""
class ProxmoxAuthenticationError(ProxmoxError):
class ProxmoxAuthenticationError(HomeAssistantError):
"""Error to indicate an authentication error."""
class ProxmoxConnectionError(ProxmoxError):
"""Error to indicate a connection error."""

View File

@@ -101,18 +101,12 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
except ResourceException as err:
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="no_nodes_found",
translation_placeholders={"error": repr(err)},
) from err
except requests.exceptions.ConnectionError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> dict[str, ProxmoxNodeData]:
"""Fetch data from Proxmox VE API."""
@@ -139,18 +133,12 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
except ResourceException as err:
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="no_nodes_found",
translation_placeholders={"error": repr(err)},
) from err
except requests.exceptions.ConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
data: dict[str, ProxmoxNodeData] = {}
for node, (vms, containers) in zip(nodes, vms_containers, strict=True):

View File

@@ -188,10 +188,6 @@
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
},
"deprecated_yaml_import_issue_connect_timeout": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"

View File

@@ -482,6 +482,9 @@
"mqtt_unauthorized": {
"message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration."
},
"multiple_maps_in_clean": {
"message": "All segments must belong to the same map. Got segments from maps: {map_flags}"
},
"no_coordinators": {
"message": "No devices were able to successfully setup"
},
@@ -491,6 +494,9 @@
"position_not_found": {
"message": "Robot position not found"
},
"segment_id_parse_error": {
"message": "Invalid segment ID format: {segment_id}"
},
"update_data_fail": {
"message": "Failed to update data"
},

View File

@@ -1,5 +1,6 @@
"""Support for Roborock vacuum class."""
import asyncio
import logging
from typing import Any
@@ -13,11 +14,11 @@ from homeassistant.components.vacuum import (
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import HomeAssistant, ServiceResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .const import DOMAIN, MAP_SLEEP
from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockConfigEntry,
@@ -120,26 +121,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
self._home_trait = coordinator.properties_api.home
self._maps_trait = coordinator.properties_api.maps
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.
Creates a repair issue when the vacuum reports different segments than
what was available when the area mapping was last configured.
"""
super()._handle_coordinator_update()
last_seen = self.last_seen_segments
if last_seen is None:
# No area mapping has been configured yet; nothing to check.
return
current_ids = {
f"{map_flag}_{room.segment_id}"
for map_flag, map_info in (self._home_trait.home_map_info or {}).items()
for room in map_info.rooms
}
if current_ids != {seg.id for seg in last_seen}:
self.async_create_segments_issue()
@property
def fan_speed_list(self) -> list[str]:
"""Get the list of available fan speeds."""
@@ -211,7 +192,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
return []
return [
Segment(
id=f"{map_flag}_{room.segment_id}",
id=f"{map_flag}:{room.segment_id}",
name=room.name,
group=map_info.name,
)
@@ -223,21 +204,51 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
"""Clean the specified segments."""
parsed: list[tuple[int, int]] = []
for seg_id in segment_ids:
map_flag_str, room_id_str = seg_id.split("_", maxsplit=1)
parsed.append((int(map_flag_str), int(room_id_str)))
# Segment id is mapflag:segment_id
parts = seg_id.split(":")
if len(parts) != 2:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="segment_id_parse_error",
translation_placeholders={"segment_id": seg_id},
)
try:
# We need to make sure both parts are ints.
parsed.append((int(parts[0]), int(parts[1])))
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="segment_id_parse_error",
translation_placeholders={"segment_id": seg_id},
) from err
# Segments from other maps are silently ignored; only segments
# belonging to the currently active map are cleaned.
current_map = self._maps_trait.current_map
current_map_segments = [
seg_id for map_flag, seg_id in parsed if map_flag == current_map
]
if not current_map_segments:
return
# Because segment_ids can overlap for each map,
# we need to make sure that only one map is passed in.
unique_map_flags = {map_flag for map_flag, _ in parsed}
if len(unique_map_flags) > 1:
map_flags_str = ", ".join(str(flag) for flag in sorted(unique_map_flags))
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="multiple_maps_in_clean",
translation_placeholders={"map_flags": map_flags_str},
)
target_map_flag = next(iter(unique_map_flags))
if self._maps_trait.current_map != target_map_flag:
# If the user is attempting to clean an area on a map that is not selected, we should try to change.
try:
await self._maps_trait.set_current_map(target_map_flag)
except RoborockException as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"command": "load_multi_map"},
) from err
await asyncio.sleep(MAP_SLEEP)
# We can now confirm all segments are on our current map, so clean them all.
await self.send(
RoborockCommand.APP_SEGMENT_CLEAN,
[{"segments": current_map_segments}],
[{"segments": [seg_id for _, seg_id in parsed]}],
)
async def async_send_command(

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
from typing import Any
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
@@ -242,9 +241,9 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
async def async_start_session(
self,
duration: timedelta = timedelta(minutes=120),
duration: int = 120,
target_temperature: int = 80,
fan_duration: timedelta = timedelta(minutes=10),
fan_duration: int = 10,
) -> None:
"""Start a sauna session with custom parameters."""
if self.coordinator.data.door_open:
@@ -255,15 +254,11 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
try:
# Set all parameters before starting the session
await self.coordinator.client.async_set_sauna_duration(
int(duration.total_seconds() // 60)
)
await self.coordinator.client.async_set_sauna_duration(duration)
await self.coordinator.client.async_set_target_temperature(
target_temperature
)
await self.coordinator.client.async_set_fan_duration(
int(fan_duration.total_seconds() // 60)
)
await self.coordinator.client.async_set_fan_duration(fan_duration)
await self.coordinator.client.async_start_session()
except SaunumException as err:
raise HomeAssistantError(

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from datetime import timedelta
from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE
import voluptuous as vol
@@ -29,22 +27,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_START_SESSION,
entity_domain=CLIMATE_DOMAIN,
schema={
vol.Optional(ATTR_DURATION, default=timedelta(minutes=120)): vol.All(
cv.time_period,
vol.Range(
min=timedelta(minutes=1),
max=timedelta(minutes=MAX_DURATION),
),
vol.Optional(ATTR_DURATION, default=120): vol.All(
cv.positive_int, vol.Range(min=1, max=MAX_DURATION)
),
vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All(
cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(ATTR_FAN_DURATION, default=timedelta(minutes=10)): vol.All(
cv.time_period,
vol.Range(
min=timedelta(minutes=1),
max=timedelta(minutes=MAX_FAN_DURATION),
),
vol.Optional(ATTR_FAN_DURATION, default=10): vol.All(
cv.positive_int, vol.Range(min=1, max=MAX_FAN_DURATION)
),
},
func="async_start_session",

View File

@@ -1,7 +1,6 @@
"""Common base for entities."""
from dataclasses import dataclass
import logging
from typing import Any
from pysmarlaapi import Federwiege
@@ -11,8 +10,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription
from .const import DEVICE_MODEL_NAME, DOMAIN, MANUFACTURER_NAME
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class SmarlaEntityDescription(EntityDescription):
@@ -33,7 +30,6 @@ class SmarlaBaseEntity(Entity):
def __init__(self, federwiege: Federwiege, desc: SmarlaEntityDescription) -> None:
"""Initialise the entity."""
self.entity_description = desc
self._federwiege = federwiege
self._property = federwiege.get_property(desc.service, desc.property)
self._attr_unique_id = f"{federwiege.serial_number}-{desc.key}"
self._attr_device_info = DeviceInfo(
@@ -43,35 +39,15 @@ class SmarlaBaseEntity(Entity):
manufacturer=MANUFACTURER_NAME,
serial_number=federwiege.serial_number,
)
self._unavailable_logged = False
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._federwiege.available
async def on_availability_change(self, available: bool) -> None:
"""Handle availability changes."""
if not self.available and not self._unavailable_logged:
_LOGGER.info("Entity %s is unavailable", self.entity_id)
self._unavailable_logged = True
elif self.available and self._unavailable_logged:
_LOGGER.info("Entity %s is back online", self.entity_id)
self._unavailable_logged = False
# Notify ha that state changed
self.async_write_ha_state()
async def on_change(self, value: Any) -> None:
async def on_change(self, value: Any):
"""Notify ha when state changes."""
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
await self._federwiege.add_listener(self.on_availability_change)
await self._property.add_listener(self.on_change)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
await self._property.remove_listener(self.on_change)
await self._federwiege.remove_listener(self.on_availability_change)

View File

@@ -24,9 +24,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/teltonika",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["teltasync==0.1.3"]
}

View File

@@ -30,10 +30,8 @@ rules:
status: exempt
comment: No custom actions registered.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow
docs-installation-parameters: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -46,12 +44,12 @@ rules:
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyuptimerobot"],
"quality_scale": "gold",
"quality_scale": "bronze",
"requirements": ["pyuptimerobot==24.0.1"]
}

View File

@@ -30,7 +30,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
entity-unavailable:
status: todo
comment: Change the type of the coordinator data to be a dict[str, UptimeRobotMonitor] so we can just do a dict look up instead of iterating over the whole list
integration-owner: done
log-when-unavailable: done
parallel-updates: done

View File

@@ -25,7 +25,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/wiz",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pywizlight==0.6.3"]
}

View File

@@ -7766,7 +7766,7 @@
},
"wiz": {
"name": "WiZ",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},

View File

@@ -657,6 +657,19 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
return True
class EntityNumericalStateChangedTriggerBase(EntityTriggerBase):
"""Trigger for numerical state changes."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected one."""
try:
float(state.state)
except TypeError, ValueError:
# State is not a valid number, don't trigger
return False
return True
CONF_LOWER_LIMIT = "lower_limit"
CONF_UPPER_LIMIT = "upper_limit"
CONF_THRESHOLD_TYPE = "threshold_type"
@@ -855,6 +868,19 @@ def make_entity_numerical_state_attribute_crossed_threshold_trigger(
return CustomTrigger
def make_entity_numerical_state_changed_trigger(
domain: str,
) -> type[EntityNumericalStateChangedTriggerBase]:
"""Create a trigger for numerical state change."""
class CustomTrigger(EntityNumericalStateChangedTriggerBase):
"""Trigger for numerical state changes."""
_domain = domain
return CustomTrigger
def make_entity_target_state_attribute_trigger(
domain: str, attribute: str, to_state: str
) -> type[EntityTargetStateAttributeTriggerBase]:

2
requirements_all.txt generated
View File

@@ -1788,7 +1788,7 @@ poolsense==0.0.8
# homeassistant.components.powerfox
# homeassistant.components.powerfox_local
powerfox==2.1.1
powerfox==2.1.0
# homeassistant.components.prana
prana-api-client==0.10.0

View File

@@ -1543,7 +1543,7 @@ poolsense==0.0.8
# homeassistant.components.powerfox
# homeassistant.components.powerfox_local
powerfox==2.1.1
powerfox==2.1.0
# homeassistant.components.prana
prana-api-client==0.10.0
@@ -2819,9 +2819,6 @@ zha==0.0.90
# homeassistant.components.zinvolt
zinvolt==0.1.0
# homeassistant.components.zoneminder
zm-py==0.5.4
# homeassistant.components.zwave_js
zwave-js-server-python==0.68.0

View File

@@ -1,15 +1,10 @@
"""Tests for the Ubiquity airOS integration."""
from airos.airos6 import AirOS6Data
from airos.airos8 import AirOS8Data
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, patch
AirOSData = AirOS8Data | AirOS6Data
async def setup_integration(
hass: HomeAssistant,

View File

@@ -3,36 +3,19 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from airos.airos6 import AirOS6Data
from airos.airos8 import AirOS8Data
from airos.helpers import DetectDeviceData
import pytest
from homeassistant.components.airos.const import DEFAULT_USERNAME, DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from . import AirOSData
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def ap_fixture(request: pytest.FixtureRequest) -> AirOSData:
"""Load fixture data for airOS device."""
def ap_fixture():
"""Load fixture data for AP mode."""
json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN)
if hasattr(request, "param"):
json_data = load_json_object_fixture(request.param, DOMAIN)
fwversion = json_data.get("host", {}).get("fwversion", "v0.0.0")
try:
fw_major = int(fwversion.lstrip("v").split(".", 1)[0])
except (ValueError, AttributeError) as err:
raise RuntimeError(
f"Could not parse firmware version from '{fwversion}'"
) from err
if fw_major == 6:
return AirOS6Data.from_dict(json_data)
return AirOS8Data.from_dict(json_data)
@@ -50,18 +33,15 @@ def mock_airos_class() -> Generator[MagicMock]:
"""Fixture to mock the AirOS class itself."""
with (
patch("homeassistant.components.airos.AirOS8", autospec=True) as mock_class,
patch("homeassistant.components.airos.AirOS6", new=mock_class),
patch("homeassistant.components.airos.config_flow.AirOS8", new=mock_class),
patch("homeassistant.components.airos.config_flow.AirOS6", new=mock_class),
patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_class),
patch("homeassistant.components.airos.coordinator.AirOS6", new=mock_class),
):
yield mock_class
@pytest.fixture
def mock_airos_client(
mock_airos_class: MagicMock, ap_fixture: AirOSData
mock_airos_class: MagicMock, ap_fixture: AirOS8Data
) -> Generator[AsyncMock]:
"""Fixture to mock the AirOS API client."""
client = mock_airos_class.return_value
@@ -94,28 +74,3 @@ def mock_discovery_method() -> Generator[AsyncMock]:
new_callable=AsyncMock,
) as mock_method:
yield mock_method
@pytest.fixture
def mock_async_get_firmware_data(ap_fixture: AirOSData):
"""Fixture to mock async_get_firmware_data to not do a network call."""
fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
return_value = DetectDeviceData(
fw_major=fw_major,
mac=ap_fixture.derived.mac,
hostname=ap_fixture.host.hostname,
)
mock = AsyncMock(return_value=return_value)
with (
patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=mock,
),
patch(
"homeassistant.components.airos.async_get_firmware_data",
new=mock,
),
):
yield mock

View File

@@ -1,168 +0,0 @@
{
"airview": {
"enabled": 0
},
"derived": {
"access_point": false,
"fw_major": 6,
"mac": "XX:XX:XX:XX:XX:XX",
"mac_interface": "br0",
"mode": "point_to_point",
"ptmp": false,
"ptp": true,
"role": "station",
"sku": "NSM5",
"station": true
},
"firewall": {
"eb6tables": false,
"ebtables": true,
"ip6tables": false,
"iptables": false
},
"genuine": "/images/genuine.png",
"host": {
"cpubusy": 3786414,
"cpuload": 25.51,
"cputotal": 14845531,
"devmodel": "NanoStation M5 ",
"freeram": 42516480,
"fwprefix": "XW",
"fwversion": "v6.3.16",
"hostname": "NanoStation M5",
"netrole": "bridge",
"totalram": 63627264,
"uptime": 148479
},
"interfaces": [
{
"enabled": true,
"hwaddr": "00:00:00:00:00:00",
"ifname": "lo",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "eth0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 100
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "eth1",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": false,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "wifi0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "ath0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "br0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
}
],
"services": {
"dhcpc": false,
"dhcpd": false,
"pppoe": false
},
"unms": {
"status": 1,
"timestamp": ""
},
"wireless": {
"ack": 5,
"antenna": "Built in - 16 dBi",
"antenna_gain": 16,
"apmac": "xxxxxxxx",
"aprepeater": 0,
"cac_nol": 0,
"ccq": 991,
"chains": "2X2",
"chanbw": 40,
"channel": 36,
"countrycode": 840,
"dfs": 0,
"distance": 750,
"essid": "Nano",
"frequency": 5180,
"hide_essid": 0,
"ieeemode": "11NAHT40PLUS",
"mode": "sta",
"noisef": -99,
"nol_chans": 0,
"opmode": "11NAHT40PLUS",
"qos": "No QoS",
"rssi": 32,
"rstatus": 5,
"rxrate": "216",
"security": "WPA2",
"signal": -64,
"throughput": {
"rx": 216,
"tx": 270
},
"txpower": 24,
"txrate": "270",
"wds": 1
}
}

View File

@@ -1,154 +0,0 @@
{
"airview": {
"enabled": 0
},
"derived": {
"access_point": false,
"fw_major": 6,
"mac": "YY:YY:YY:YY:YY:YY",
"mac_interface": "br0",
"mode": "point_to_point",
"ptmp": false,
"ptp": true,
"role": "station",
"sku": "LocoM5",
"station": true
},
"firewall": {
"eb6tables": false,
"ebtables": true,
"ip6tables": false,
"iptables": false
},
"genuine": "/images/genuine.png",
"host": {
"cpubusy": 11150046,
"cpuload": 5.65,
"cputotal": 197455604,
"devmodel": "NanoStation loco M5 ",
"freeram": 8753152,
"fwprefix": "XM",
"fwversion": "v6.3.16",
"hostname": "NanoStation loco M5 Client",
"netrole": "bridge",
"totalram": 30220288,
"uptime": 1974859
},
"interfaces": [
{
"enabled": true,
"hwaddr": "00:00:00:00:00:00",
"ifname": "lo",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "YY:YY:YY:YY:YY:YY",
"ifname": "eth0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": false,
"ip6addr": null,
"plugged": false,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "YY:YY:YY:YY:YY:YY",
"ifname": "wifi0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "YY:YY:YY:YY:YY:YY",
"ifname": "ath0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 300
}
},
{
"enabled": true,
"hwaddr": "YY:YY:YY:YY:YY:YY",
"ifname": "br0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
}
],
"services": {
"dhcpc": false,
"dhcpd": false,
"pppoe": false
},
"unms": {
"status": 0,
"timestamp": ""
},
"wireless": {
"ack": 28,
"antenna": "Built in - 13 dBi",
"antenna_gain": 13,
"apmac": "XX:XX:XX:XX:XX:XX",
"aprepeater": 0,
"cac_nol": 0,
"ccq": 738,
"chains": "2X2",
"chanbw": 40,
"channel": 140,
"countrycode": 616,
"dfs": 0,
"distance": 600,
"essid": "SOMETHING",
"frequency": 5700,
"hide_essid": 0,
"ieeemode": "11NAHT40MINUS",
"mode": "sta",
"noisef": -89,
"nol_chans": 0,
"opmode": "11naht40minus",
"qos": "No QoS",
"rssi": 50,
"rstatus": 5,
"rxrate": "180",
"security": "WPA2",
"signal": -46,
"throughput": {
"rx": 180,
"tx": 243
},
"txpower": 2,
"txrate": "243",
"wds": 1
}
}

View File

@@ -1,520 +0,0 @@
{
"chain_names": [
{
"name": "Chain 0",
"number": 1
},
{
"name": "Chain 1",
"number": 2
}
],
"derived": {
"access_point": true,
"fw_major": 8,
"mac": "04:11:22:33:19:7E",
"mac_interface": "br0",
"mode": "point_to_multipoint",
"ptmp": true,
"ptp": false,
"role": "access_point",
"sku": "LAP-GPS",
"station": false
},
"firewall": {
"eb6tables": false,
"ebtables": false,
"ip6tables": false,
"iptables": false
},
"genuine": "/images/genuine.png",
"gps": {
"alt": 252.5,
"dim": 3,
"dop": 1.52,
"fix": 1,
"lat": 52.379894,
"lon": 4.901608,
"sats": 8,
"time_synced": null
},
"host": {
"cpuload": 59.595959,
"device_id": "b222d222222f2222f0e2ecbcc22d2e22",
"devmodel": "LiteAP GPS",
"freeram": 13541376,
"fwversion": "v8.7.18",
"height": null,
"hostname": "House-Bridge",
"loadavg": 0.188965,
"netrole": "bridge",
"power_time": 1461661,
"temperature": 0,
"time": "2025-08-06 16:37:55",
"timestamp": 2148019169,
"totalram": 63447040,
"uptime": 81655
},
"interfaces": [
{
"enabled": true,
"hwaddr": "05:11:22:33:19:7E",
"ifname": "eth0",
"mtu": 1500,
"status": {
"cable_len": 48,
"duplex": true,
"ip6addr": null,
"ipaddr": "0.0.0.0",
"plugged": true,
"rx_bytes": 17307482485,
"rx_dropped": 0,
"rx_errors": 0,
"rx_packets": 208585470,
"snr": [30, 30, 29, 29],
"speed": 1000,
"tx_bytes": 268785703196,
"tx_dropped": 1,
"tx_errors": 0,
"tx_packets": 212573426
}
},
{
"enabled": true,
"hwaddr": "04:11:22:33:19:7E",
"ifname": "ath0",
"mtu": 1500,
"status": {
"cable_len": null,
"duplex": false,
"ip6addr": null,
"ipaddr": "0.0.0.0",
"plugged": false,
"rx_bytes": 274358042002,
"rx_dropped": 0,
"rx_errors": 0,
"rx_packets": 212583924,
"snr": null,
"speed": 0,
"tx_bytes": 16450464430,
"tx_dropped": 227,
"tx_errors": 0,
"tx_packets": 150354889
}
},
{
"enabled": true,
"hwaddr": "04:11:22:33:19:7E",
"ifname": "br0",
"mtu": 1500,
"status": {
"cable_len": null,
"duplex": false,
"ip6addr": null,
"ipaddr": "127.0.0.85",
"plugged": true,
"rx_bytes": 6053730278,
"rx_dropped": 0,
"rx_errors": 0,
"rx_packets": 51908268,
"snr": null,
"speed": 0,
"tx_bytes": 38072153,
"tx_dropped": 0,
"tx_errors": 0,
"tx_packets": 99493
}
}
],
"ntpclient": {
"last_sync": "2025-08-06 16:28:17"
},
"portfw": false,
"provmode": {},
"services": {
"airview": 2,
"dhcp6d_stateful": false,
"dhcpc": true,
"dhcpd": false,
"pppoe": false
},
"unms": {
"status": 0,
"timestamp": null
},
"wireless": {
"antenna_gain": 17,
"apmac": "03:11:22:33:19:7E",
"aprepeater": false,
"band": 2,
"cac_state": 0,
"cac_timeout": 0,
"center1_freq": 5690,
"chanbw": 40,
"compat_11n": 1,
"count": 2,
"dfs": 1,
"distance": 300,
"essid": "House-shed1",
"frequency": 5680,
"hide_essid": 0,
"ieeemode": "11ACVHT40",
"mode": "ap-ptmp",
"noisef": -92,
"nol_state": 0,
"nol_timeout": 0,
"polling": {
"atpc_status": 2,
"cb_capacity": 342000,
"dl_capacity": 342000,
"ff_cap_rep": false,
"fixed_frame": false,
"flex_mode": 1,
"gps_sync": false,
"rx_use": 98,
"tx_use": 168,
"ul_capacity": 342000,
"use": 266
},
"rstatus": 5,
"rx_chainmask": 3,
"rx_idx": 9,
"rx_nss": 2,
"security": "WPA2",
"service": {
"link": 1461418,
"time": 1461511
},
"sta": [
{
"airmax": {
"actual_priority": 2,
"atpc_status": 4,
"beam": 0,
"cb_capacity": 343800,
"desired_priority": 2,
"dl_capacity": 342000,
"rx": {
"cinr": 33,
"evm": [
[
33, 34, 37, 33, 34, 34, 35, 34, 35, 36, 33, 33, 31, 34, 33, 33,
33, 32, 32, 33, 34, 31, 33, 34, 34, 36, 33, 35, 34, 34, 33, 35,
34, 36, 33, 39, 31, 33, 34, 35, 31, 29, 32, 33, 36, 33, 33, 33,
32, 35, 33, 32, 32, 32, 35, 37, 34, 34, 32, 34, 36, 33, 32, 31
],
[
42, 42, 42, 42, 43, 42, 42, 43, 43, 43, 42, 42, 42, 41, 43, 42,
42, 43, 42, 42, 42, 42, 43, 43, 43, 42, 44, 42, 42, 42, 42, 42,
43, 43, 41, 42, 42, 41, 42, 42, 42, 42, 42, 42, 41, 42, 41, 43,
41, 42, 42, 43, 41, 44, 43, 43, 43, 42, 44, 42, 42, 42, 42, 42
]
],
"usage": 34
},
"tx": {
"cinr": 33,
"evm": [
[
34, 31, 33, 35, 34, 35, 34, 31, 34, 33, 33, 29, 26, 35, 34, 35,
35, 28, 32, 32, 32, 27, 28, 36, 34, 32, 31, 33, 28, 34, 35, 33,
33, 34, 37, 33, 33, 32, 29, 32, 34, 37, 29, 33, 32, 33, 33, 32,
29, 32, 32, 31, 32, 32, 35, 32, 33, 31, 35, 33, 33, 30, 32, 33
],
[
44, 43, 43, 43, 43, 43, 43, 44, 42, 44, 43, 43, 43, 44, 44, 44,
44, 44, 44, 44, 43, 43, 44, 44, 43, 42, 43, 43, 43, 44, 43, 43,
43, 43, 44, 44, 44, 43, 44, 43, 44, 43, 43, 43, 43, 43, 43, 43,
42, 43, 43, 43, 43, 43, 43, 43, 42, 43, 43, 43, 43, 43, 42, 44
]
],
"usage": 6
},
"ul_capacity": 345600
},
"airos_connected": true,
"cb_capacity_expect": 286000,
"chainrssi": [43, 41, 0],
"distance": 300,
"dl_avg_linkscore": 100,
"dl_capacity_expect": 260000,
"dl_linkscore": 100,
"dl_rate_expect": 7,
"dl_signal_expect": -68,
"last_disc": 0,
"lastip": "127.0.0.82",
"mac": "00:11:22:33:2E:05",
"noisefloor": -92,
"remote": {
"age": 3,
"airview": 2,
"antenna_gain": 19,
"cable_loss": 0,
"chainrssi": [44, 39, 0],
"compat_11n": 0,
"cpuload": 13.0,
"device_id": "22222dd22222c2e2b22d0d2222aedb38",
"distance": 300,
"ethlist": [
{
"cable_len": 1,
"duplex": true,
"enabled": true,
"ifname": "eth0",
"plugged": true,
"snr": [30, 29, 30, 30],
"speed": 1000
},
{
"cable_len": 0,
"duplex": true,
"enabled": true,
"ifname": "eth1",
"plugged": false,
"snr": [0, 0, 0, 0],
"speed": 0
}
],
"freeram": 20488192,
"gps": {
"alt": null,
"dim": null,
"dop": null,
"fix": 0,
"lat": 52.379894,
"lon": 4.901608,
"sats": null,
"time_synced": null
},
"height": 2,
"hostname": "NanoBeam-shed2",
"ip6addr": null,
"ipaddr": ["127.0.0.82"],
"mode": "sta-ptmp",
"netrole": "bridge",
"noisefloor": -94,
"oob": false,
"platform": "NanoBeam 5AC",
"power_time": 16088831,
"rssi": 45,
"rx_bytes": 6168364701,
"rx_chainmask": 3,
"rx_throughput": 755,
"service": {
"link": 16087519,
"time": 16088594
},
"signal": -51,
"sys_id": "0xe7fc",
"temperature": 0,
"time": "2025-08-06 16:37:53",
"totalram": 63447040,
"tx_bytes": 35767943005,
"tx_power": -4,
"tx_ratedata": [2, 0, 1, 9, 4150, 89921, 4, 4, 28560, 7836817],
"tx_throughput": 4666,
"unms": {
"status": 0,
"timestamp": null
},
"uptime": 82335,
"version": "WA.ar934x.v8.7.18.48247.250728.0850"
},
"rssi": 45,
"rx_idx": 9,
"rx_nss": 2,
"signal": -51,
"stats": {
"rx_bytes": 35530195638,
"rx_packets": 30533587,
"rx_pps": 256,
"tx_bytes": 6597810092,
"tx_packets": 47272530,
"tx_pps": 0
},
"tx_idx": 9,
"tx_latency": 1,
"tx_lretries": 0,
"tx_nss": 2,
"tx_packets": 0,
"tx_ratedata": [2, 0, 1, 8, 8, 15523, 4, 4, 867, 7181836],
"tx_sretries": 0,
"ul_avg_linkscore": 100,
"ul_capacity_expect": 312000,
"ul_linkscore": 100,
"ul_rate_expect": 8,
"ul_signal_expect": -62,
"uptime": 81581
},
{
"airmax": {
"actual_priority": 2,
"atpc_status": 4,
"beam": 0,
"cb_capacity": 342000,
"desired_priority": 2,
"dl_capacity": 342000,
"rx": {
"cinr": 33,
"evm": [
[
32, 31, 34, 35, 32, 33, 31, 36, 35, 32, 41, 34, 34, 34, 31, 31,
33, 30, 34, 35, 32, 34, 31, 30, 33, 32, 29, 29, 36, 34, 32, 32,
32, 33, 33, 34, 33, 34, 35, 33, 34, 33, 33, 33, 33, 29, 32, 32,
31, 33, 33, 34, 34, 31, 34, 33, 29, 34, 34, 32, 30, 32, 32, 33
],
[
50, 53, 51, 51, 51, 50, 53, 50, 52, 50, 51, 51, 50, 50, 49, 50,
52, 51, 50, 50, 51, 51, 50, 50, 52, 50, 50, 51, 50, 50, 50, 51,
50, 50, 49, 50, 50, 53, 51, 51, 50, 51, 52, 51, 51, 51, 51, 50,
50, 50, 52, 50, 50, 50, 50, 51, 51, 50, 51, 51, 52, 52, 53, 53
]
],
"usage": 62
},
"tx": {
"cinr": 34,
"evm": [
[
35, 34, 33, 34, 32, 32, 35, 31, 34, 32, 37, 34, 35, 33, 33, 32,
32, 33, 36, 33, 34, 33, 31, 35, 34, 35, 33, 33, 35, 32, 34, 34,
34, 33, 33, 34, 35, 36, 33, 33, 33, 36, 34, 36, 36, 33, 34, 34,
33, 34, 34, 34, 30, 32, 37, 35, 35, 35, 33, 35, 34, 32, 33, 34
],
[
51, 52, 52, 50, 51, 52, 52, 51, 52, 51, 52, 52, 50, 52, 51, 52,
52, 51, 52, 51, 51, 51, 51, 51, 52, 51, 51, 51, 52, 51, 51, 51,
51, 51, 51, 51, 51, 51, 51, 52, 52, 51, 51, 51, 51, 51, 51, 51,
52, 51, 51, 51, 52, 52, 51, 50, 52, 52, 52, 51, 51, 52, 50, 51
]
],
"usage": 21
},
"ul_capacity": 342000
},
"airos_connected": true,
"cb_capacity_expect": 286000,
"chainrssi": [50, 51, 0],
"distance": 300,
"dl_avg_linkscore": 100,
"dl_capacity_expect": 260000,
"dl_linkscore": 100,
"dl_rate_expect": 7,
"dl_signal_expect": -68,
"last_disc": 0,
"lastip": "127.0.0.90",
"mac": "01:11:22:33:31:38",
"noisefloor": -92,
"remote": {
"age": 2,
"airview": 2,
"antenna_gain": 19,
"cable_loss": 0,
"chainrssi": [50, 52, 0],
"compat_11n": 0,
"cpuload": 23.5294,
"device_id": "2b2b22222b222222aa2fd22a22222c2c",
"distance": 300,
"ethlist": [
{
"cable_len": 1,
"duplex": true,
"enabled": true,
"ifname": "eth0",
"plugged": true,
"snr": [30, 30, 30, 30],
"speed": 1000
},
{
"cable_len": 0,
"duplex": true,
"enabled": true,
"ifname": "eth1",
"plugged": false,
"snr": [0, 0, 0, 0],
"speed": 0
}
],
"freeram": 19714048,
"gps": {
"alt": null,
"dim": null,
"dop": null,
"fix": 0,
"lat": 52.379894,
"lon": 4.901608,
"sats": null,
"time_synced": null
},
"height": 2,
"hostname": "NanoBeam-shed1",
"ip6addr": null,
"ipaddr": ["127.0.0.90"],
"mode": "sta-ptmp",
"netrole": "bridge",
"noisefloor": -91,
"oob": false,
"platform": "NanoBeam 5AC",
"power_time": 1461670,
"rssi": 54,
"rx_bytes": 14205619701,
"rx_chainmask": 3,
"rx_throughput": 1322,
"service": {
"link": 1461239,
"time": 1461517
},
"signal": -42,
"sys_id": "0xe7fc",
"temperature": 0,
"time": "2025-08-06 16:37:53",
"totalram": 63447040,
"tx_bytes": 242792119032,
"tx_power": -4,
"tx_ratedata": [2, 0, 1, 8, 5296, 373316, 5, 788, 4003255, 21672948],
"tx_throughput": 23354,
"unms": {
"status": 0,
"timestamp": null
},
"uptime": 83207,
"version": "WA.ar934x.v8.7.18.48247.250728.0850"
},
"rssi": 54,
"rx_idx": 9,
"rx_nss": 2,
"signal": -42,
"stats": {
"rx_bytes": 238827846427,
"rx_packets": 182050337,
"rx_pps": 2641,
"tx_bytes": 14544700857,
"tx_packets": 131651046,
"tx_pps": 0
},
"tx_idx": 9,
"tx_latency": 1,
"tx_lretries": 0,
"tx_nss": 2,
"tx_packets": 0,
"tx_ratedata": [2, 0, 1, 8, 12, 31932, 4, 12, 363974, 20502633],
"tx_sretries": 0,
"ul_avg_linkscore": 100,
"ul_capacity_expect": 312000,
"ul_linkscore": 100,
"ul_rate_expect": 8,
"ul_signal_expect": -62,
"uptime": 81580
}
],
"sta_disconnected": [],
"throughput": {
"rx": 24259,
"tx": 1565
},
"tx_chainmask": 3,
"tx_idx": 9,
"tx_nss": 2,
"txpower": -1
}
}

View File

@@ -11,7 +11,6 @@
],
"derived": {
"access_point": true,
"fw_major": 8,
"mac": "01:23:45:67:89:AB",
"mac_interface": "br0",
"mode": "point_to_point",

View File

@@ -1,554 +1,5 @@
# serializer version: 1
# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_client-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_m5_dhcp_client',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCP client',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP client',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_client',
'unique_id': 'XX:XX:XX:XX:XX:XX_dhcp_client',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_client-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation M5 DHCP client',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_m5_dhcp_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_server-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_m5_dhcp_server',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCP server',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP server',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_server',
'unique_id': 'XX:XX:XX:XX:XX:XX_dhcp_server',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation M5 DHCP server',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_m5_dhcp_server',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_pppoe_link-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_m5_pppoe_link',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'PPPoE link',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'PPPoE link',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pppoe',
'unique_id': 'XX:XX:XX:XX:XX:XX_pppoe',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_pppoe_link-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'NanoStation M5 PPPoE link',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_m5_pppoe_link',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_NanoStation_loco_M5_v6.3.16_XM_sta.json][binary_sensor.nanostation_loco_m5_client_dhcp_client-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_loco_m5_client_dhcp_client',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCP client',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP client',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_client',
'unique_id': 'YY:YY:YY:YY:YY:YY_dhcp_client',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_NanoStation_loco_M5_v6.3.16_XM_sta.json][binary_sensor.nanostation_loco_m5_client_dhcp_client-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation loco M5 Client DHCP client',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_loco_m5_client_dhcp_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_NanoStation_loco_M5_v6.3.16_XM_sta.json][binary_sensor.nanostation_loco_m5_client_dhcp_server-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_loco_m5_client_dhcp_server',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCP server',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP server',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_server',
'unique_id': 'YY:YY:YY:YY:YY:YY_dhcp_server',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_NanoStation_loco_M5_v6.3.16_XM_sta.json][binary_sensor.nanostation_loco_m5_client_dhcp_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation loco M5 Client DHCP server',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_loco_m5_client_dhcp_server',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_NanoStation_loco_M5_v6.3.16_XM_sta.json][binary_sensor.nanostation_loco_m5_client_pppoe_link-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_loco_m5_client_pppoe_link',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'PPPoE link',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'PPPoE link',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pppoe',
'unique_id': 'YY:YY:YY:YY:YY:YY_pppoe',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_NanoStation_loco_M5_v6.3.16_XM_sta.json][binary_sensor.nanostation_loco_m5_client_pppoe_link-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'NanoStation loco M5 Client PPPoE link',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_loco_m5_client_pppoe_link',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_dhcp_client-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.house_bridge_dhcp_client',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCP client',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP client',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_client',
'unique_id': '04:11:22:33:19:7E_dhcp_client',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_dhcp_client-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'House-Bridge DHCP client',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.house_bridge_dhcp_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_dhcp_server-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.house_bridge_dhcp_server',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCP server',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP server',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_server',
'unique_id': '04:11:22:33:19:7E_dhcp_server',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_dhcp_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'House-Bridge DHCP server',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.house_bridge_dhcp_server',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_dhcpv6_server-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.house_bridge_dhcpv6_server',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHCPv6 server',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCPv6 server',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp6_server',
'unique_id': '04:11:22:33:19:7E_dhcp6_server',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_dhcpv6_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'House-Bridge DHCPv6 server',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.house_bridge_dhcpv6_server',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_port_forwarding-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.house_bridge_port_forwarding',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Port forwarding',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Port forwarding',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'port_forwarding',
'unique_id': '04:11:22:33:19:7E_portfw',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_port_forwarding-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'House-Bridge Port forwarding',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.house_bridge_port_forwarding',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_pppoe_link-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.house_bridge_pppoe_link',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'PPPoE link',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'PPPoE link',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pppoe',
'unique_id': '04:11:22:33:19:7E_pppoe',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_liteapgps_ap_ptmp_40mhz.json][binary_sensor.house_bridge_pppoe_link-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'House-Bridge PPPoE link',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.house_bridge_pppoe_link',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_client-entry]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -584,7 +35,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_client-state]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
@@ -598,7 +49,7 @@
'state': 'off',
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_server-entry]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -634,7 +85,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_server-state]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
@@ -648,7 +99,7 @@
'state': 'off',
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-entry]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -684,7 +135,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
@@ -698,7 +149,7 @@
'state': 'off',
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_port_forwarding-entry]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -734,7 +185,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_port_forwarding-state]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'NanoStation 5AC ap name Port forwarding',
@@ -747,7 +198,7 @@
'state': 'off',
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_pppoe_link-entry]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -783,7 +234,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_pppoe_link-state]
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',

View File

@@ -14,7 +14,7 @@
]),
'derived': dict({
'access_point': True,
'fw_major': 8,
'fw_major': None,
'mac': '**REDACTED**',
'mac_interface': 'br0',
'mode': 'point_to_point',

View File

@@ -14,24 +14,13 @@ from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.parametrize(
("ap_fixture"),
[
"airos_loco5ac_ap-ptp.json", # v8 ptp
"airos_liteapgps_ap_ptmp_40mhz.json", # v8 ptmp
"airos_NanoStation_loco_M5_v6.3.16_XM_sta.json", # v6 XM (different login process)
"airos_NanoStation_M5_sta_v6.3.16.json", # v6 XW
],
indirect=True,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])

View File

@@ -21,7 +21,6 @@ REBOOT_ENTITY_ID = "button.nanostation_5ac_ap_name_restart"
async def test_reboot_button_press_success(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
@@ -46,7 +45,6 @@ async def test_reboot_button_press_success(
async def test_reboot_button_press_fail(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that pressing the reboot button utilizes the correct calls."""
@@ -76,7 +74,6 @@ async def test_reboot_button_press_fail(
async def test_reboot_button_press_exceptions(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
) -> None:

View File

@@ -1,7 +1,7 @@
"""Test the Ubiquiti airOS config flow."""
from typing import Any
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -11,7 +11,6 @@ from airos.exceptions import (
AirOSKeyDataMissingError,
AirOSListenerError,
)
from airos.helpers import DetectDeviceData
import pytest
import voluptuous as vol
@@ -23,12 +22,7 @@ from homeassistant.components.airos.const import (
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import (
SOURCE_DHCP,
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
SOURCE_USER,
)
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -40,8 +34,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import AirOSData
from tests.common import MockConfigEntry
NEW_PASSWORD = "new_password"
@@ -84,10 +76,9 @@ MOCK_DISC_EXISTS = {
async def test_manual_flow_creates_entry(
hass: HomeAssistant,
ap_fixture: dict[str, Any],
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_setup_entry: AsyncMock,
mock_airos_client: AsyncMock,
ap_fixture: dict[str, Any],
) -> None:
"""Test we get the user form and create the appropriate entry."""
result = await hass.config_entries.flow.async_init(
@@ -119,7 +110,6 @@ async def test_manual_flow_creates_entry(
async def test_form_duplicate_entry(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test the form does not allow duplicate entries."""
mock_entry = MockConfigEntry(
@@ -159,48 +149,35 @@ async def test_form_duplicate_entry(
async def test_form_exception_handling(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
ap_fixture: dict[str, Any],
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle exceptions."""
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=exception,
):
flow_start = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
mock_airos_client.login.side_effect = exception
menu = await hass.config_entries.flow.async_configure(
flow_start["flow_id"], {"next_step_id": "manual"}
)
flow_start = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
menu["flow_id"], MOCK_CONFIG
)
menu = await hass.config_entries.flow.async_configure(
flow_start["flow_id"], {"next_step_id": "manual"}
)
result = await hass.config_entries.flow.async_configure(
menu["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
valid_data = DetectDeviceData(
fw_major=fw_major,
mac=ap_fixture.derived.mac,
hostname=ap_fixture.host.hostname,
)
mock_airos_client.login.side_effect = None
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
return_value=valid_data,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "NanoStation 5AC ap name"
@@ -210,7 +187,6 @@ async def test_form_exception_handling(
async def test_reauth_flow_scenario(
hass: HomeAssistant,
ap_fixture: AirOSData,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
@@ -220,37 +196,18 @@ async def test_reauth_flow_scenario(
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
flow = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": mock_config_entry.entry_id},
data=mock_config_entry.data,
)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flow["type"] == FlowResultType.FORM
flow = flows[0]
assert flow["step_id"] == REAUTH_STEP
fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
valid_data = DetectDeviceData(
fw_major=fw_major,
mac=ap_fixture.derived.mac,
hostname=ap_fixture.host.hostname,
mock_airos_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
with (
patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=AsyncMock(return_value=valid_data),
),
patch(
"homeassistant.components.airos.async_get_firmware_data",
new=AsyncMock(return_value=valid_data),
),
):
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
# Always test resolution
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
@@ -276,61 +233,41 @@ async def test_reauth_flow_scenario(
)
async def test_reauth_flow_scenarios(
hass: HomeAssistant,
ap_fixture: AirOSData,
expected_error: str,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
reauth_exception: Exception,
expected_error: str,
) -> None:
"""Test reauthentication from start (failure) to finish (success)."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=AirOSConnectionAuthenticationError,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
flow = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": mock_config_entry.entry_id},
data=mock_config_entry.data,
)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flow["type"] == FlowResultType.FORM
flow = flows[0]
assert flow["step_id"] == REAUTH_STEP
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=reauth_exception,
):
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
mock_airos_client.login.side_effect = reauth_exception
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == REAUTH_STEP
assert result["errors"] == {"base": expected_error}
fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
valid_data = DetectDeviceData(
fw_major=fw_major,
mac=ap_fixture.derived.mac,
hostname=ap_fixture.host.hostname,
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=AsyncMock(return_value=valid_data),
):
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == REAUTH_STEP
assert result["errors"] == {"base": expected_error}
assert result["type"] == FlowResultType.ABORT
mock_airos_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
@@ -339,42 +276,26 @@ async def test_reauth_flow_scenarios(
async def test_reauth_unique_id_mismatch(
hass: HomeAssistant,
ap_fixture: AirOSData,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauthentication failure when the unique ID changes."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=AirOSConnectionAuthenticationError,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
flow = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": mock_config_entry.entry_id},
data=mock_config_entry.data,
)
flows = hass.config_entries.flow.async_progress()
flow = flows[0]
fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
valid_data = DetectDeviceData(
fw_major=fw_major,
mac="FF:23:45:67:89:AB",
hostname=ap_fixture.host.hostname,
mock_airos_client.login.side_effect = None
mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB"
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=AsyncMock(return_value=valid_data),
):
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
@@ -385,7 +306,6 @@ async def test_reauth_unique_id_mismatch(
async def test_successful_reconfigure(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful reconfigure."""
@@ -443,11 +363,10 @@ async def test_successful_reconfigure(
)
async def test_reconfigure_flow_failure(
hass: HomeAssistant,
expected_error: str,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
reconfigure_exception: Exception,
expected_error: str,
) -> None:
"""Test reconfigure from start (failure) to finish (success)."""
mock_config_entry.add_to_hass(hass)
@@ -467,19 +386,18 @@ async def test_reconfigure_flow_failure(
},
}
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=reconfigure_exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
)
mock_airos_client.login.side_effect = reconfigure_exception
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == RECONFIGURE_STEP
assert result["errors"] == {"base": expected_error}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == RECONFIGURE_STEP
assert result["errors"] == {"base": expected_error}
mock_airos_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
@@ -494,9 +412,7 @@ async def test_reconfigure_flow_failure(
async def test_reconfigure_unique_id_mismatch(
hass: HomeAssistant,
ap_fixture: AirOSData,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfiguration failure when the unique ID changes."""
@@ -509,12 +425,7 @@ async def test_reconfigure_unique_id_mismatch(
)
flow_id = result["flow_id"]
fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
mismatched_data = DetectDeviceData(
fw_major=fw_major,
mac="FF:23:45:67:89:AB",
hostname=ap_fixture.host.hostname,
)
mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB"
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
@@ -524,14 +435,10 @@ async def test_reconfigure_unique_id_mismatch(
},
}
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=AsyncMock(return_value=mismatched_data),
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input=user_input,
)
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input=user_input,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
@@ -545,8 +452,7 @@ async def test_reconfigure_unique_id_mismatch(
async def test_discover_flow_no_devices_found(
hass: HomeAssistant,
mock_discovery_method: AsyncMock,
hass: HomeAssistant, mock_discovery_method
) -> None:
"""Test discovery flow aborts when no devices are found."""
mock_discovery_method.return_value = {}
@@ -568,10 +474,7 @@ async def test_discover_flow_no_devices_found(
async def test_discover_flow_one_device_found(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_discovery_method: AsyncMock,
mock_setup_entry: AsyncMock,
hass: HomeAssistant, mock_discovery_method, mock_airos_client, mock_setup_entry
) -> None:
"""Test discovery flow goes straight to credentials when one device is found."""
mock_discovery_method.return_value = {MOCK_DISC_DEV1[MAC_ADDRESS]: MOCK_DISC_DEV1}
@@ -591,24 +494,18 @@ async def test_discover_flow_one_device_found(
assert result["step_id"] == "configure_device"
assert result["description_placeholders"]["device_name"] == MOCK_DISC_DEV1[HOSTNAME]
valid_data = DetectDeviceData(
fw_major=8,
mac=MOCK_DISC_DEV1[MAC_ADDRESS],
hostname=MOCK_DISC_DEV1[HOSTNAME],
)
# Provide credentials and complete the flow
mock_airos_client.status.return_value.derived.mac = MOCK_DISC_DEV1[MAC_ADDRESS]
mock_airos_client.status.return_value.host.hostname = MOCK_DISC_DEV1[HOSTNAME]
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=AsyncMock(return_value=valid_data),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_DISC_DEV1[HOSTNAME]
@@ -616,11 +513,7 @@ async def test_discover_flow_one_device_found(
async def test_discover_flow_multiple_devices_found(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_discovery_method: AsyncMock,
mock_setup_entry: AsyncMock,
hass: HomeAssistant, mock_discovery_method, mock_airos_client, mock_setup_entry
) -> None:
"""Test discovery flow with multiple devices found, requiring a selection step."""
mock_discovery_method.return_value = {
@@ -667,24 +560,18 @@ async def test_discover_flow_multiple_devices_found(
assert result["step_id"] == "configure_device"
assert result["description_placeholders"]["device_name"] == MOCK_DISC_DEV1[HOSTNAME]
valid_data = DetectDeviceData(
fw_major=8,
mac=MOCK_DISC_DEV1[MAC_ADDRESS],
hostname=MOCK_DISC_DEV1[HOSTNAME],
)
# Provide credentials and complete the flow
mock_airos_client.status.return_value.derived.mac = MOCK_DISC_DEV1[MAC_ADDRESS]
mock_airos_client.status.return_value.host.hostname = MOCK_DISC_DEV1[HOSTNAME]
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
new=AsyncMock(return_value=valid_data),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_DISC_DEV1[HOSTNAME]
@@ -692,9 +579,7 @@ async def test_discover_flow_multiple_devices_found(
async def test_discover_flow_with_existing_device(
hass: HomeAssistant,
mock_discovery_method: AsyncMock,
mock_airos_client: AsyncMock,
hass: HomeAssistant, mock_discovery_method, mock_airos_client
) -> None:
"""Test that discovery ignores devices that are already configured."""
# Add a mock config entry for an existing device
@@ -757,9 +642,7 @@ async def test_discover_flow_discovery_exceptions(
async def test_configure_device_flow_exceptions(
hass: HomeAssistant,
mock_discovery_method: AsyncMock,
mock_airos_client: AsyncMock,
hass: HomeAssistant, mock_discovery_method, mock_airos_client
) -> None:
"""Test configure_device step handles authentication and connection exceptions."""
mock_discovery_method.return_value = {MOCK_DISC_DEV1[MAC_ADDRESS]: MOCK_DISC_DEV1}
@@ -771,34 +654,30 @@ async def test_configure_device_flow_exceptions(
result["flow_id"], {"next_step_id": "discovery"}
)
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=AirOSConnectionAuthenticationError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "wrong-user",
CONF_PASSWORD: "wrong-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "wrong-user",
CONF_PASSWORD: "wrong-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
with patch(
"homeassistant.components.airos.config_flow.async_get_firmware_data",
side_effect=AirOSDeviceConnectionError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "some-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
mock_airos_client.login.side_effect = AirOSDeviceConnectionError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "some-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}

View File

@@ -1,6 +1,6 @@
"""Diagnostic tests for airOS."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
from syrupy.assertion import SnapshotAssertion
@@ -21,7 +21,6 @@ async def test_diagnostics(
mock_config_entry: MockConfigEntry,
ap_fixture: AirOS8Data,
snapshot: SnapshotAssertion,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test diagnostics."""

View File

@@ -2,14 +2,8 @@
from __future__ import annotations
from unittest.mock import ANY, AsyncMock, MagicMock
from unittest.mock import ANY, MagicMock
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
)
import pytest
from homeassistant.components.airos.const import (
@@ -63,9 +57,8 @@ MOCK_CONFIG_V1_2 = {
async def test_setup_entry_with_default_ssl(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_airos_class: MagicMock,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
mock_airos_class: MagicMock,
) -> None:
"""Test setting up a config entry with default SSL options."""
mock_config_entry.add_to_hass(hass)
@@ -89,9 +82,8 @@ async def test_setup_entry_with_default_ssl(
async def test_setup_entry_without_ssl(
hass: HomeAssistant,
mock_airos_class: MagicMock,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
mock_airos_class: MagicMock,
) -> None:
"""Test setting up a config entry adjusted to plain HTTP."""
entry = MockConfigEntry(
@@ -122,9 +114,7 @@ async def test_setup_entry_without_ssl(
async def test_ssl_migrate_entry(
hass: HomeAssistant,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
hass: HomeAssistant, mock_airos_client: MagicMock
) -> None:
"""Test migrate entry SSL options."""
entry = MockConfigEntry(
@@ -155,12 +145,11 @@ async def test_ssl_migrate_entry(
)
async def test_uid_migrate_entry(
hass: HomeAssistant,
mock_airos_client: MagicMock,
device_registry: dr.DeviceRegistry,
sensor_domain: str,
sensor_name: str,
mock_id: str,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test migrate entry unique id."""
entity_registry = er.async_get(hass)
@@ -216,7 +205,6 @@ async def test_uid_migrate_entry(
async def test_migrate_future_return(
hass: HomeAssistant,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test migrate entry unique id."""
entry = MockConfigEntry(
@@ -237,9 +225,8 @@ async def test_migrate_future_return(
async def test_load_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup and unload config entry."""
mock_config_entry.add_to_hass(hass)
@@ -253,32 +240,3 @@ async def test_load_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("exception", "state"),
[
(AirOSConnectionAuthenticationError, ConfigEntryState.SETUP_ERROR),
(AirOSConnectionSetupError, ConfigEntryState.SETUP_RETRY),
(AirOSDeviceConnectionError, ConfigEntryState.SETUP_RETRY),
(AirOSKeyDataMissingError, ConfigEntryState.SETUP_ERROR),
(Exception, ConfigEntryState.SETUP_ERROR),
],
)
async def test_setup_entry_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_airos_class: MagicMock,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
exception: Exception,
state: ConfigEntryState,
) -> None:
"""Test config entry setup failure."""
mock_async_get_firmware_data.side_effect = exception
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert result is False
assert mock_config_entry.state == state

View File

@@ -22,10 +22,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
@@ -47,7 +46,6 @@ async def test_sensor_update_exception_handling(
mock_config_entry: MockConfigEntry,
exception: Exception,
freezer: FrozenDateTimeFactory,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test entity update data handles exceptions."""
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])

View File

@@ -1,29 +1,17 @@
"""Tests for the ecobee config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from pyecobee import ECOBEE_PASSWORD, ECOBEE_USERNAME
import pytest
from unittest.mock import patch
from homeassistant.components.ecobee import config_flow
from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Generator[AsyncMock]:
"""Prevent the actual integration from being set up."""
with patch(
"homeassistant.components.ecobee.async_setup_entry", return_value=True
) as mock:
yield mock
async def test_abort_if_already_setup(hass: HomeAssistant) -> None:
"""Test we abort if ecobee is already setup."""
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
@@ -38,223 +26,91 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None:
async def test_user_step_without_user_input(hass: HomeAssistant) -> None:
"""Test expected result if user step is called."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
result = await flow.async_step_user()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
async def test_pin_request_succeeds(hass: HomeAssistant) -> None:
"""Test expected result if pin request succeeds."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
mock_ecobee.request_pin.return_value = True
mock_ecobee.pin = "test-pin"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_KEY: "api-key"}
)
result = await flow.async_step_user(user_input={CONF_API_KEY: "api-key"})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
assert result["description_placeholders"] == {
"pin": "test-pin",
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
}
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
assert result["description_placeholders"] == {
"pin": "test-pin",
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
}
async def test_pin_request_fails(hass: HomeAssistant) -> None:
"""Test expected result if pin request fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
mock_ecobee.request_pin.return_value = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_KEY: "api-key"}
)
result = await flow.async_step_user(user_input={CONF_API_KEY: "api-key"})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "pin_request_failed"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "pin_request_failed"
async def test_token_request_succeeds(hass: HomeAssistant) -> None:
"""Test expected result if token request succeeds."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
with patch(
"homeassistant.components.ecobee.config_flow.Ecobee"
) as mock_flow_ecobee:
flow_instance = mock_flow_ecobee.return_value
flow_instance.request_pin.return_value = True
flow_instance.pin = "test-pin"
flow_instance.request_tokens.return_value = True
flow_instance.api_key = "test-api-key"
flow_instance.refresh_token = "test-token"
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
mock_ecobee.request_tokens.return_value = True
mock_ecobee.api_key = "test-api-key"
mock_ecobee.refresh_token = "test-token"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_KEY: "api-key"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
flow._ecobee = mock_ecobee
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await flow.async_step_authorize(user_input={})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DOMAIN
assert result["data"] == {
CONF_API_KEY: "test-api-key",
CONF_REFRESH_TOKEN: "test-token",
}
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DOMAIN
assert result["data"] == {
CONF_API_KEY: "test-api-key",
CONF_REFRESH_TOKEN: "test-token",
}
async def test_token_request_fails(hass: HomeAssistant) -> None:
"""Test expected result if token request fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
flow = config_flow.EcobeeFlowHandler()
flow.hass = hass
with patch(
"homeassistant.components.ecobee.config_flow.Ecobee"
) as mock_flow_ecobee:
flow_instance = mock_flow_ecobee.return_value
flow_instance.request_pin.return_value = True
flow_instance.pin = "test-pin"
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
mock_ecobee = mock_ecobee.return_value
mock_ecobee.request_tokens.return_value = False
mock_ecobee.pin = "test-pin"
flow._ecobee = mock_ecobee
result = await flow.async_step_authorize(user_input={})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_KEY: "api-key"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
flow_instance.request_tokens.return_value = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
assert result["errors"]["base"] == "token_request_failed"
assert result["description_placeholders"] == {
"pin": "test-pin",
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
}
async def test_password_login_succeeds(hass: HomeAssistant) -> None:
"""Test credential authentication succeeds."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.ecobee.config_flow.Ecobee"
) as mock_flow_ecobee:
flow_instance = mock_flow_ecobee.return_value
flow_instance.refresh_tokens.return_value = True
flow_instance.refresh_token = "test-token"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USERNAME: "test-username@example.com",
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DOMAIN
assert result["data"] == {
CONF_USERNAME: "test-username@example.com",
CONF_PASSWORD: "test-password",
CONF_REFRESH_TOKEN: "test-token",
}
mock_flow_ecobee.assert_called_once_with(
config={
ECOBEE_USERNAME: "test-username@example.com",
ECOBEE_PASSWORD: "test-password",
assert result["errors"]["base"] == "token_request_failed"
assert result["description_placeholders"] == {
"pin": "test-pin",
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
}
)
flow_instance.refresh_tokens.assert_called_once_with()
@pytest.mark.parametrize(
("first_user_input", "expected_error"),
[
(
{
CONF_USERNAME: "test-username@example.com",
CONF_PASSWORD: "test-password",
},
"login_failed",
),
(
{
CONF_API_KEY: "test-api-key",
CONF_USERNAME: "test-username@example.com",
CONF_PASSWORD: "test-password",
},
"invalid_auth",
),
],
)
async def test_password_login_error_recovers(
hass: HomeAssistant,
first_user_input: dict,
expected_error: str,
) -> None:
"""Test that authentication errors keep the user on the form and recover on retry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.ecobee.config_flow.Ecobee"
) as mock_flow_ecobee:
mock_flow_ecobee.return_value.refresh_tokens.return_value = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=first_user_input
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == expected_error
with patch(
"homeassistant.components.ecobee.config_flow.Ecobee"
) as mock_flow_ecobee:
flow_instance = mock_flow_ecobee.return_value
flow_instance.refresh_tokens.return_value = True
flow_instance.refresh_token = "test-token"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USERNAME: "test-username@example.com",
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DOMAIN
assert result["data"] == {
CONF_USERNAME: "test-username@example.com",
CONF_PASSWORD: "test-password",
CONF_REFRESH_TOKEN: "test-token",
}

View File

@@ -678,49 +678,6 @@ async def test_hmip_light_hs(
}
async def test_hmip_light_hs_monochrome(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None:
"""Test HomematicipLight with monochrome mode (no hue/saturation support)."""
entity_id = "light.rgbw_controller_channel1"
entity_name = "RGBW Controller Channel1"
device_model = "HmIP-RGBW"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["RGBW Controller"]
)
ha_state, hmip_device = get_and_check_entity_basics(
hass, mock_hap, entity_id, entity_name, device_model
)
# Simulate monochrome mode by setting hue and saturationLevel to None
await async_manipulate_test_data(hass, hmip_device, "hue", None, channel=1)
await async_manipulate_test_data(
hass, hmip_device, "saturationLevel", None, channel=1
)
await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0.5, channel=1)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_ON
assert ha_state.attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS
assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS]
service_call_counter = len(hmip_device.functionalChannels[1].mock_calls)
# Test turning on in monochrome mode - should use set_dim_level_async
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": entity_id, ATTR_BRIGHTNESS: 200},
blocking=True,
)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1
assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "set_dim_level_async"
assert hmip_device.functionalChannels[1].mock_calls[-1][2] == {
"dim_level": 0.78,
}
async def test_hmip_wired_push_button_led(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None:

View File

@@ -1,36 +1,20 @@
"""Test the liebherr integration init."""
import copy
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pyliebherrhomeapi import (
Device,
DeviceState,
DeviceType,
IceMakerControl,
IceMakerMode,
TemperatureControl,
TemperatureUnit,
ToggleControl,
ZonePosition,
)
from pyliebherrhomeapi.exceptions import (
LiebherrAuthenticationError,
LiebherrConnectionError,
)
import pytest
from homeassistant.components.liebherr.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
from .conftest import MOCK_DEVICE
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import MockConfigEntry
# Test errors during initial get_devices() call in async_setup_entry
@@ -101,173 +85,3 @@ async def test_unload_entry(
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
NEW_DEVICE = Device(
device_id="new_device_id",
nickname="New Fridge",
device_type=DeviceType.FRIDGE,
device_name="K2601",
)
NEW_DEVICE_STATE = DeviceState(
device=NEW_DEVICE,
controls=[
TemperatureControl(
zone_id=1,
zone_position=ZonePosition.TOP,
name="Fridge",
type="fridge",
value=4,
target=5,
min=2,
max=8,
unit=TemperatureUnit.CELSIUS,
),
ToggleControl(
name="supercool",
type="ToggleControl",
zone_id=1,
zone_position=ZonePosition.TOP,
value=False,
),
IceMakerControl(
name="icemaker",
type="IceMakerControl",
zone_id=1,
zone_position=ZonePosition.TOP,
ice_maker_mode=IceMakerMode.OFF,
has_max_ice=False,
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_dynamic_device_discovery_no_new_devices(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device scan with no new devices does not create entities."""
# Same devices returned
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE]
initial_states = len(hass.states.async_all())
freezer.tick(timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# No new entities should be created
assert len(hass.states.async_all()) == initial_states
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"exception",
[
LiebherrConnectionError("Connection failed"),
LiebherrAuthenticationError("Auth failed"),
],
ids=["connection_error", "auth_error"],
)
async def test_dynamic_device_discovery_api_error(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test device scan gracefully handles API errors."""
mock_liebherr_client.get_devices.side_effect = exception
initial_states = len(hass.states.async_all())
freezer.tick(timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# No crash, no new entities
assert len(hass.states.async_all()) == initial_states
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("init_integration")
async def test_dynamic_device_discovery_coordinator_setup_failure(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device scan skips devices that fail coordinator setup."""
# New device appears but its state fetch fails
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE]
original_state = copy.deepcopy(MOCK_DEVICE_STATE)
mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: (
copy.deepcopy(original_state)
if device_id == "test_device_id"
else (_ for _ in ()).throw(LiebherrConnectionError("Device offline"))
)
freezer.tick(timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# New device should NOT be added
assert "new_device_id" not in mock_config_entry.runtime_data.coordinators
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_dynamic_device_discovery(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test new devices are automatically discovered on all platforms."""
mock_config_entry.add_to_hass(hass)
all_platforms = [
Platform.SENSOR,
Platform.NUMBER,
Platform.SWITCH,
Platform.SELECT,
]
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", all_platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Initially only the original device exists
assert hass.states.get("sensor.test_fridge_top_zone") is not None
assert hass.states.get("sensor.new_fridge") is None
# Simulate a new device appearing on the account
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE]
mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: (
copy.deepcopy(
NEW_DEVICE_STATE if device_id == "new_device_id" else MOCK_DEVICE_STATE
)
)
# Advance time to trigger device scan (5 minute interval)
freezer.tick(timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# New device should have entities on all platforms
state = hass.states.get("sensor.new_fridge")
assert state is not None
assert state.state == "4"
assert hass.states.get("number.new_fridge_setpoint") is not None
assert hass.states.get("switch.new_fridge_supercool") is not None
assert hass.states.get("select.new_fridge_icemaker") is not None
# Original device should still exist
assert hass.states.get("sensor.test_fridge_top_zone") is not None
# Runtime data should have both coordinators
assert "new_device_id" in mock_config_entry.runtime_data.coordinators
assert "test_device_id" in mock_config_entry.runtime_data.coordinators

View File

@@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import MOCK_DEVICE
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -100,6 +100,70 @@ async def test_single_zone_number(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_multi_zone_with_none_position(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
platforms: list[Platform],
) -> None:
"""Test multi-zone device with None zone_position falls back to base translation key."""
device = Device(
device_id="multi_zone_none",
nickname="Multi Zone Fridge",
device_type=DeviceType.COMBI,
device_name="CBNes9999",
)
mock_liebherr_client.get_devices.return_value = [device]
multi_zone_state = DeviceState(
device=device,
controls=[
TemperatureControl(
zone_id=1,
zone_position=None, # None triggers fallback
name="Fridge",
type="fridge",
value=5,
target=4,
min=2,
max=8,
unit=TemperatureUnit.CELSIUS,
),
TemperatureControl(
zone_id=2,
zone_position=ZonePosition.BOTTOM,
name="Freezer",
type="freezer",
value=-18,
target=-18,
min=-24,
max=-16,
unit=TemperatureUnit.CELSIUS,
),
],
)
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
multi_zone_state
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Zone with None position should have base translation key
zone1_entity = entity_registry.async_get("number.multi_zone_fridge_setpoint")
assert zone1_entity is not None
assert zone1_entity.translation_key == "setpoint_temperature"
# Zone with valid position should have zone-specific translation key
zone2_entity = entity_registry.async_get(
"number.multi_zone_fridge_bottom_zone_setpoint"
)
assert zone2_entity is not None
assert zone2_entity.translation_key == "setpoint_temperature_bottom_zone"
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature(
hass: HomeAssistant,
@@ -152,6 +216,50 @@ async def test_set_temperature_failure(
)
@pytest.mark.usefixtures("init_integration")
async def test_number_update_failure(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test number becomes unavailable when coordinator update fails and recovers."""
entity_id = "number.test_fridge_top_zone_setpoint"
# Initial state should be available with value
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "4"
# Simulate update error
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError(
"Connection failed"
)
# Advance time to trigger coordinator refresh (60 second interval)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Number should now be unavailable
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Simulate recovery
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
MOCK_DEVICE_STATE
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Number should recover
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "4"
@pytest.mark.usefixtures("init_integration")
async def test_number_when_control_missing(
hass: HomeAssistant,

View File

@@ -182,6 +182,46 @@ async def test_select_failure(
)
@pytest.mark.usefixtures("init_integration")
async def test_select_update_failure(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test select becomes unavailable when coordinator update fails and recovers."""
entity_id = "select.test_fridge_bottom_zone_icemaker"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
# Simulate update error
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError(
"Connection failed"
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Simulate recovery
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
MOCK_DEVICE_STATE
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
@pytest.mark.usefixtures("init_integration")
async def test_select_when_control_missing(
hass: HomeAssistant,
@@ -267,6 +307,70 @@ async def test_single_zone_select(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_multi_zone_with_none_position(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
platforms: list[Platform],
) -> None:
"""Test multi-zone device where zone_position is None."""
device = Device(
device_id="multi_none_id",
nickname="Multi None Fridge",
device_type=DeviceType.COMBI,
device_name="CBNes5678",
)
mock_liebherr_client.get_devices.return_value = [device]
state = DeviceState(
device=device,
controls=[
TemperatureControl(
zone_id=1,
zone_position=None,
name="Fridge",
type="fridge",
value=4,
target=4,
min=2,
max=8,
unit=TemperatureUnit.CELSIUS,
),
TemperatureControl(
zone_id=2,
zone_position=None,
name="Freezer",
type="freezer",
value=-18,
target=-18,
min=-24,
max=-16,
unit=TemperatureUnit.CELSIUS,
),
IceMakerControl(
name="icemaker",
type="IceMakerControl",
zone_id=1,
zone_position=None,
ice_maker_mode=IceMakerMode.OFF,
has_max_ice=True,
),
],
)
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
state
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Without zone_position, should use the base translation key (no zone suffix)
entity_state = hass.states.get("select.multi_none_fridge_icemaker")
assert entity_state is not None
assert entity_state.state == "off"
@pytest.mark.usefixtures("init_integration")
async def test_select_current_option_none_mode(
hass: HomeAssistant,

View File

@@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import MOCK_DEVICE
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -150,6 +150,46 @@ async def test_switch_failure(
)
@pytest.mark.usefixtures("init_integration")
async def test_switch_update_failure(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test switch becomes unavailable when coordinator update fails and recovers."""
entity_id = "switch.test_fridge_top_zone_supercool"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
# Simulate update error
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError(
"Connection failed"
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Simulate recovery
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
MOCK_DEVICE_STATE
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
@pytest.mark.usefixtures("init_integration")
async def test_switch_when_control_missing(
hass: HomeAssistant,

View File

@@ -1,15 +1,10 @@
"""Provide common Lutron fixtures and mocks."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
from pylutron import OccupancyGroup
import pytest
from homeassistant.components.lutron.const import DOMAIN
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@@ -18,112 +13,3 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.lutron.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_lutron() -> Generator[MagicMock]:
"""Mock Lutron client."""
with (
patch("homeassistant.components.lutron.Lutron", autospec=True) as mock_lutron,
patch("homeassistant.components.lutron.config_flow.Lutron", new=mock_lutron),
):
client = mock_lutron.return_value
client.guid = "12345678901"
client.areas = []
# Mock an area
area = MagicMock()
area.name = "Test Area"
area.outputs = []
area.keypads = []
area.occupancy_group = None
client.areas.append(area)
# Mock a light
light = MagicMock()
light.name = "Test Light"
light.id = "light_id"
light.uuid = "light_uuid"
light.legacy_uuid = "light_legacy_uuid"
light.is_dimmable = True
light.type = "LIGHT"
light.last_level.return_value = 0
area.outputs.append(light)
# Mock a switch
switch = MagicMock()
switch.name = "Test Switch"
switch.id = "switch_id"
switch.uuid = "switch_uuid"
switch.legacy_uuid = "switch_legacy_uuid"
switch.is_dimmable = False
switch.type = "NON_DIM"
switch.last_level.return_value = 0
area.outputs.append(switch)
# Mock a cover
cover = MagicMock()
cover.name = "Test Cover"
cover.id = "cover_id"
cover.uuid = "cover_uuid"
cover.legacy_uuid = "cover_legacy_uuid"
cover.type = "SYSTEM_SHADE"
cover.last_level.return_value = 0
area.outputs.append(cover)
# Mock a fan
fan = MagicMock()
fan.name = "Test Fan"
fan.uuid = "fan_uuid"
fan.legacy_uuid = "fan_legacy_uuid"
fan.type = "CEILING_FAN_TYPE"
fan.last_level.return_value = 0
area.outputs.append(fan)
# Mock a keypad with a button and LED
keypad = MagicMock()
keypad.name = "Test Keypad"
keypad.id = "keypad_id"
keypad.type = "KEYPAD"
area.keypads.append(keypad)
button = MagicMock()
button.name = "Test Button"
button.number = 1
button.button_type = "SingleAction"
button.uuid = "button_uuid"
button.legacy_uuid = "button_legacy_uuid"
keypad.buttons = [button]
led = MagicMock()
led.name = "Test LED"
led.number = 1
led.uuid = "led_uuid"
led.legacy_uuid = "led_legacy_uuid"
led.last_state = 0
keypad.leds = [led]
# Mock an occupancy group
occ_group = MagicMock()
occ_group.name = "Test Occupancy"
occ_group.id = "occ_id"
occ_group.uuid = "occ_uuid"
occ_group.legacy_uuid = "occ_legacy_uuid"
occ_group.state = OccupancyGroup.State.VACANT
area.occupancy_group = occ_group
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a Lutron config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
"host": "127.0.0.1",
"username": "lutron",
"password": "password",
},
unique_id="12345678901",
)

View File

@@ -1,52 +0,0 @@
# serializer version: 1
# name: test_binary_sensor_setup[binary_sensor.test_occupancy_occupancy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.test_occupancy_occupancy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Occupancy',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.OCCUPANCY: 'occupancy'>,
'original_icon': None,
'original_name': 'Occupancy',
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345678901_occ_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_setup[binary_sensor.test_occupancy_occupancy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'occupancy',
'friendly_name': 'Test Occupancy Occupancy',
'lutron_integration_id': 'occ_id',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.test_occupancy_occupancy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -1,53 +0,0 @@
# serializer version: 1
# name: test_cover_setup[cover.test_cover-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_cover',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 7>,
'translation_key': None,
'unique_id': '12345678901_cover_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_cover_setup[cover.test_cover-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 0,
'friendly_name': 'Test Cover',
'lutron_integration_id': 'cover_id',
'supported_features': <CoverEntityFeature: 7>,
}),
'context': <ANY>,
'entity_id': 'cover.test_cover',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'closed',
})
# ---

View File

@@ -1,58 +0,0 @@
# serializer version: 1
# name: test_event_setup[event.test_keypad_test_button-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
<LutronEventType.SINGLE_PRESS: 'single_press'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.test_keypad_test_button',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Test Button',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Test Button',
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'button',
'unique_id': '12345678901_button_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_event_setup[event.test_keypad_test_button-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'event_type': None,
'event_types': list([
<LutronEventType.SINGLE_PRESS: 'single_press'>,
]),
'friendly_name': 'Test Keypad Test Button',
}),
'context': <ANY>,
'entity_id': 'event.test_keypad_test_button',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -1,57 +0,0 @@
# serializer version: 1
# name: test_fan_setup[fan.test_fan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'preset_modes': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.test_fan',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <FanEntityFeature: 49>,
'translation_key': None,
'unique_id': '12345678901_fan_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_fan_setup[fan.test_fan-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Fan',
'percentage': 0,
'percentage_step': 33.333333333333336,
'preset_mode': None,
'preset_modes': None,
'supported_features': <FanEntityFeature: 49>,
}),
'context': <ANY>,
'entity_id': 'fan.test_fan',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -1,61 +0,0 @@
# serializer version: 1
# name: test_light_setup[light.test_light-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.test_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <LightEntityFeature: 40>,
'translation_key': None,
'unique_id': '12345678901_light_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_light_setup[light.test_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': None,
'color_mode': None,
'friendly_name': 'Test Light',
'lutron_integration_id': 'light_id',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 40>,
}),
'context': <ANY>,
'entity_id': 'light.test_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -1,50 +0,0 @@
# serializer version: 1
# name: test_scene_setup[scene.test_keypad_test_button-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'scene',
'entity_category': None,
'entity_id': 'scene.test_keypad_test_button',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Test Button',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Test Button',
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345678901_button_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_scene_setup[scene.test_keypad_test_button-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Keypad Test Button',
}),
'context': <ANY>,
'entity_id': 'scene.test_keypad_test_button',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -1,103 +0,0 @@
# serializer version: 1
# name: test_switch_setup[switch.test_keypad_test_button-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.test_keypad_test_button',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Test Button',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Test Button',
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345678901_led_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_switch_setup[switch.test_keypad_test_button-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Keypad Test Button',
'keypad': 'Test Keypad',
'led': 'Test LED',
'scene': 'Test Button',
}),
'context': <ANY>,
'entity_id': 'switch.test_keypad_test_button',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switch_setup[switch.test_switch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.test_switch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lutron',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345678901_switch_uuid',
'unit_of_measurement': None,
})
# ---
# name: test_switch_setup[switch.test_switch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Switch',
'lutron_integration_id': 'switch_id',
}),
'context': <ANY>,
'entity_id': 'switch.test_switch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -1,56 +0,0 @@
"""Test Lutron binary sensor platform."""
from unittest.mock import MagicMock, patch
from pylutron import OccupancyGroup
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_binary_sensor_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test binary sensor setup."""
mock_config_entry.add_to_hass(hass)
occ_group = mock_lutron.areas[0].occupancy_group
occ_group.state = OccupancyGroup.State.VACANT
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_binary_sensor_update(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test binary sensor update."""
mock_config_entry.add_to_hass(hass)
occ_group = mock_lutron.areas[0].occupancy_group
occ_group.state = OccupancyGroup.State.VACANT
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "binary_sensor.test_occupancy_occupancy"
assert hass.states.get(entity_id).state == STATE_OFF
# Simulate update
occ_group.state = OccupancyGroup.State.OCCUPIED
callback = occ_group.subscribe.call_args[0][0]
callback(occ_group, None, None, None)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON

View File

@@ -1,110 +0,0 @@
"""Test Lutron cover platform."""
from unittest.mock import MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
STATE_CLOSED,
STATE_OPEN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_cover_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test cover setup."""
mock_config_entry.add_to_hass(hass)
cover = mock_lutron.areas[0].outputs[2]
cover.level = 0
cover.last_level.return_value = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_cover_services(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test cover services."""
mock_config_entry.add_to_hass(hass)
cover = mock_lutron.areas[0].outputs[2]
cover.level = 0
cover.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "cover.test_cover"
# Open cover
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert cover.level == 100
# Close cover
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert cover.level == 0
# Set cover position
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: entity_id, "position": 50},
blocking=True,
)
assert cover.level == 50
async def test_cover_update(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test cover state update."""
mock_config_entry.add_to_hass(hass)
cover = mock_lutron.areas[0].outputs[2]
cover.level = 0
cover.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "cover.test_cover"
assert hass.states.get(entity_id).state == STATE_CLOSED
# Simulate update
cover.last_level.return_value = 100
callback = cover.subscribe.call_args[0][0]
callback(cover, None, None, None)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OPEN
assert hass.states.get(entity_id).attributes["current_position"] == 100

View File

@@ -1,88 +0,0 @@
"""Test Lutron event platform."""
from unittest.mock import MagicMock, patch
from pylutron import Button
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
async def test_event_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test event setup."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_event_single_press(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test single press event."""
mock_config_entry.add_to_hass(hass)
button = mock_lutron.areas[0].keypads[0].buttons[0]
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Subscribe to events
events = async_capture_events(hass, "lutron_event")
# Simulate button press
for call in button.subscribe.call_args_list:
callback = call[0][0]
callback(button, None, Button.Event.PRESSED, None)
await hass.async_block_till_done()
# Check bus event
assert len(events) == 1
assert events[0].data["action"] == "single"
assert events[0].data["uuid"] == "button_uuid"
async def test_event_press_release(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test press and release events."""
mock_config_entry.add_to_hass(hass)
button = mock_lutron.areas[0].keypads[0].buttons[0]
button.button_type = "MasterRaiseLower"
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Subscribe to events
events = async_capture_events(hass, "lutron_event")
# Simulate button press
for call in button.subscribe.call_args_list:
callback = call[0][0]
callback(button, None, Button.Event.PRESSED, None)
await hass.async_block_till_done()
assert len(events) == 1
assert events[0].data["action"] == "pressed"
# Simulate button release
for call in button.subscribe.call_args_list:
callback = call[0][0]
callback(button, None, Button.Event.RELEASED, None)
await hass.async_block_till_done()
assert len(events) == 2
assert events[1].data["action"] == "released"

View File

@@ -1,108 +0,0 @@
"""Test Lutron fan platform."""
from unittest.mock import MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_fan_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test fan setup."""
mock_config_entry.add_to_hass(hass)
fan = mock_lutron.areas[0].outputs[3]
fan.level = 0
fan.last_level.return_value = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_fan_services(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test fan services."""
mock_config_entry.add_to_hass(hass)
fan = mock_lutron.areas[0].outputs[3]
fan.level = 0
fan.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "fan.test_fan"
# Turn on (defaults to medium - 67%)
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert fan.level == 67
# Turn off
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert fan.level == 0
# Set percentage
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 33},
blocking=True,
)
assert fan.level == 33
async def test_fan_update(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test fan state update."""
mock_config_entry.add_to_hass(hass)
fan = mock_lutron.areas[0].outputs[3]
fan.level = 0
fan.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "fan.test_fan"
assert hass.states.get(entity_id).state == STATE_OFF
# Simulate update
fan.last_level.return_value = 100
callback = fan.subscribe.call_args[0][0]
callback(fan, None, None, None)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 100

View File

@@ -1,101 +0,0 @@
"""Test Lutron integration setup."""
from unittest.mock import MagicMock
from homeassistant.components.lutron.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_setup_entry(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test setting up the integration."""
mock_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "lutron", {})
await hass.async_block_till_done()
assert mock_config_entry.runtime_data.client is mock_lutron
assert len(mock_config_entry.runtime_data.lights) == 1
# Verify that the unique ID is generated correctly.
# This prevents regression in unique ID generation which would be a breaking change.
entity_registry = er.async_get(hass)
# The light from mock_lutron has uuid="light_uuid" and guid="12345678901"
expected_unique_id = "12345678901_light_uuid"
entry = entity_registry.async_get("light.test_light")
assert entry.unique_id == expected_unique_id
async def test_unload_entry(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test unloading the integration."""
mock_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "lutron", {})
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
async def test_unique_id_migration(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test migration of legacy unique IDs to the newer UUID-based format.
In older versions of the integration, unique IDs were based on a legacy UUID format.
The integration now prefers a newer UUID format when available. This test ensures
that existing entities and devices are automatically migrated to the new format
without losing their registry entries.
"""
mock_config_entry.add_to_hass(hass)
# Setup registries with an entry using the "legacy" unique ID format.
# This simulates a user who had configured the integration in an older version.
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
legacy_unique_id = "12345678901_light_legacy_uuid"
new_unique_id = "12345678901_light_uuid"
# Create a device in the registry using the legacy ID
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, legacy_unique_id)},
manufacturer="Lutron",
name="Test Light",
)
# Create an entity in the registry using the legacy ID
entity = entity_registry.async_get_or_create(
domain="light",
platform="lutron",
unique_id=legacy_unique_id,
config_entry=mock_config_entry,
device_id=device.id,
)
# Verify our starting state: registry holds the legacy ID
assert entity.unique_id == legacy_unique_id
assert (DOMAIN, legacy_unique_id) in device.identifiers
# Trigger the integration setup.
# The async_setup_entry logic will detect the legacy IDs in the registry
# and update them to the new UUIDs provided by the mock_lutron fixture.
assert await async_setup_component(hass, "lutron", {})
await hass.async_block_till_done()
# Verify that the entity's unique ID has been updated to the new format.
entity = entity_registry.async_get(entity.entity_id)
assert entity.unique_id == new_unique_id
# Verify that the device's identifiers have also been migrated.
device = device_registry.async_get(device.id)
assert (DOMAIN, new_unique_id) in device.identifiers
assert (DOMAIN, legacy_unique_id) not in device.identifiers

View File

@@ -1,222 +0,0 @@
"""Test Lutron light platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_FLASH,
ATTR_TRANSITION,
DOMAIN as LIGHT_DOMAIN,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_light_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test light setup."""
mock_config_entry.add_to_hass(hass)
light = mock_lutron.areas[0].outputs[0]
light.level = 0
light.last_level.return_value = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_light_turn_on_off(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test light turn on and off."""
mock_config_entry.add_to_hass(hass)
light = mock_lutron.areas[0].outputs[0]
light.level = 0
light.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "light.test_light"
# Turn on
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128},
blocking=True,
)
light.set_level.assert_called_with(new_level=pytest.approx(50.196, rel=1e-3))
# Turn off
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
light.set_level.assert_called_with(new_level=0)
async def test_light_update(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test light state update from library."""
mock_config_entry.add_to_hass(hass)
light = mock_lutron.areas[0].outputs[0]
light.level = 0
light.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "light.test_light"
assert hass.states.get(entity_id).state == STATE_OFF
# Simulate update from library
light.last_level.return_value = 100
# The library calls the callback registered with subscribe
callback = light.subscribe.call_args[0][0]
callback(light, None, None, None)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id).attributes[ATTR_BRIGHTNESS] == 255
async def test_light_transition(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test light turn on/off with transition."""
mock_config_entry.add_to_hass(hass)
light = mock_lutron.areas[0].outputs[0]
light.level = 0
light.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "light.test_light"
# Turn on with transition
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 2.5},
blocking=True,
)
# Default brightness is used if not specified (DEFAULT_DIMMER_LEVEL is 50%)
light.set_level.assert_called_with(
new_level=pytest.approx(50.0, abs=0.5), fade_time_seconds=2.5
)
# Turn off with transition
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 3.0},
blocking=True,
)
light.set_level.assert_called_with(new_level=0, fade_time_seconds=3.0)
async def test_light_flash(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test light flash."""
mock_config_entry.add_to_hass(hass)
light = mock_lutron.areas[0].outputs[0]
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "light.test_light"
# Short flash
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_FLASH: "short"},
blocking=True,
)
light.flash.assert_called_with(0.5)
# Long flash
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_FLASH: "long"},
blocking=True,
)
light.flash.assert_called_with(1.5)
async def test_light_brightness_restore(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test light brightness restore logic."""
mock_config_entry.add_to_hass(hass)
light = mock_lutron.areas[0].outputs[0]
light.level = 0
light.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "light.test_light"
# Turn on first time - uses default (50%)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
light.set_level.assert_called_with(new_level=pytest.approx(50.0, abs=0.5))
# Simulate update to 50% (Lutron level 50 -> HA level 127)
light.last_level.return_value = 50
callback = light.subscribe.call_args[0][0]
callback(light, None, None, None)
await hass.async_block_till_done()
# Turn off
light.last_level.return_value = 0
callback(light, None, None, None)
await hass.async_block_till_done()
# Turn on again - should restore ~50%
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
# HA level 127 -> Lutron level ~49.8
light.set_level.assert_called_with(new_level=pytest.approx(50.0, abs=0.5))

View File

@@ -1,51 +0,0 @@
"""Test Lutron scene platform."""
from unittest.mock import MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_scene_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test scene setup."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.SCENE]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_scene_activate(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test scene activation."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "scene.test_keypad_test_button"
button = mock_lutron.areas[0].keypads[0].buttons[0]
await hass.services.async_call(
SCENE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
button.tap.assert_called_once()

View File

@@ -1,110 +0,0 @@
"""Test Lutron switch platform."""
from unittest.mock import MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_switch_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test switch setup."""
mock_config_entry.add_to_hass(hass)
switch = mock_lutron.areas[0].outputs[1]
switch.level = 0
switch.last_level.return_value = 0
led = mock_lutron.areas[0].keypads[0].leds[0]
led.state = 0
led.last_state = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.SWITCH]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_switch_turn_on_off(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test switch turn on and off."""
mock_config_entry.add_to_hass(hass)
switch = mock_lutron.areas[0].outputs[1]
switch.level = 0
switch.last_level.return_value = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "switch.test_switch"
# Turn on
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert switch.level == 100
# Turn off
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert switch.level == 0
async def test_led_turn_on_off(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test LED turn on and off."""
mock_config_entry.add_to_hass(hass)
led = mock_lutron.areas[0].keypads[0].leds[0]
led.state = 0
led.last_state = 0
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "switch.test_keypad_test_button"
# Turn on
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert led.state == 1
# Turn off
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert led.state == 0

View File

@@ -0,0 +1,114 @@
"""Test number entity trigger."""
import pytest
from homeassistant.components.number.const import DOMAIN
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> list[str]:
"""Create multiple number entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"number.changed",
],
)
async def test_number_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the number entity triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
(
"number.changed",
[
{"included": {"state": None, "attributes": {}}, "count": 0},
{"included": {"state": "1", "attributes": {}}, "count": 0},
{"included": {"state": "2", "attributes": {}}, "count": 1},
],
),
(
"number.changed",
[
{"included": {"state": "1", "attributes": {}}, "count": 0},
{"included": {"state": "1.1", "attributes": {}}, "count": 1},
{"included": {"state": "1", "attributes": {}}, "count": 1},
{"included": {"state": None, "attributes": {}}, "count": 0},
{"included": {"state": "2", "attributes": {}}, "count": 0},
{"included": {"state": "1.5", "attributes": {}}, "count": 1},
],
),
(
"number.changed",
[
{"included": {"state": "1", "attributes": {}}, "count": 0},
{"included": {"state": "not a number", "attributes": {}}, "count": 0},
{"included": {"state": "2", "attributes": {}}, "count": 1},
],
),
],
)
async def test_number_changed_trigger_behavior(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_numbers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[TriggerStateDescription],
) -> None:
"""Test that the number changed trigger behaves correctly."""
other_entity_ids = set(target_numbers) - {entity_id}
# Set all numbers, including the tested number, to the initial state
for eid in target_numbers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check that changing other numbers also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()

View File

@@ -1,10 +0,0 @@
# serializer version: 1
# name: test_entry_diagnostics
dict({
'energy_return': 111111,
'energy_usage': 1111111,
'energy_usage_high_tariff': 111111,
'energy_usage_low_tariff': 111111,
'power': 111,
})
# ---

View File

@@ -1,9 +1,8 @@
"""Test the Powerfox Local config flow."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock
from powerfox import LocalResponse, PowerfoxAuthenticationError, PowerfoxConnectionError
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError
import pytest
from homeassistant.components.powerfox_local.const import DOMAIN
@@ -185,173 +184,3 @@ async def test_user_flow_exceptions(
user_input={CONF_HOST: MOCK_HOST, CONF_API_KEY: MOCK_API_KEY},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
async def test_step_reauth(
hass: HomeAssistant,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test re-authentication flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new-api-key"},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new-api-key"
@pytest.mark.parametrize(
("exception", "error"),
[
(PowerfoxConnectionError, "cannot_connect"),
(PowerfoxAuthenticationError, "invalid_auth"),
],
)
async def test_step_reauth_exceptions(
hass: HomeAssistant,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test exceptions during re-authentication flow."""
mock_powerfox_local_client.value.side_effect = exception
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new-api-key"},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": error}
# Recover from error
mock_powerfox_local_client.value.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new-api-key"},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test reconfiguration flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.200", CONF_API_KEY: MOCK_API_KEY},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.200"
assert mock_config_entry.data[CONF_API_KEY] == MOCK_API_KEY
@pytest.mark.parametrize(
("exception", "error"),
[
(PowerfoxConnectionError, "cannot_connect"),
(PowerfoxAuthenticationError, "invalid_auth"),
],
)
async def test_reconfigure_flow_exceptions(
hass: HomeAssistant,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test exceptions during reconfiguration flow."""
mock_powerfox_local_client.value.side_effect = exception
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.200", CONF_API_KEY: MOCK_API_KEY},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": error}
# Recover from error
mock_powerfox_local_client.value.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.200", CONF_API_KEY: MOCK_API_KEY},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.200"
async def test_reconfigure_flow_unique_id_mismatch(
hass: HomeAssistant,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test reconfiguration aborts on unique ID mismatch."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
# Return response with different API key (which serves as device_id)
mock_powerfox_local_client.value.return_value = LocalResponse(
timestamp=datetime(2026, 2, 25, 10, 48, 51, tzinfo=UTC),
power=111,
energy_usage=1111111,
energy_return=111111,
energy_usage_high_tariff=111111,
energy_usage_low_tariff=111111,
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.200", CONF_API_KEY: "different_api_key"},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "unique_id_mismatch"

View File

@@ -1,30 +0,0 @@
"""Test for Powerfox Local diagnostics."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Powerfox Local entry diagnostics."""
await setup_integration(hass, mock_config_entry)
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert result == snapshot

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from unittest.mock import AsyncMock
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError
from powerfox import PowerfoxConnectionError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -43,20 +43,3 @@ async def test_config_entry_not_ready(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_exception(
hass: HomeAssistant,
mock_powerfox_local_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test ConfigEntryNotReady when API raises an exception during entry setup."""
mock_config_entry.add_to_hass(hass)
mock_powerfox_local_client.value.side_effect = PowerfoxAuthenticationError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"

View File

@@ -50,8 +50,8 @@ async def test_all_entities(
(AuthenticationError("Invalid credentials")),
(SSLError("SSL handshake failed")),
(ConnectTimeout("Connection timed out")),
(ResourceException("404", "status_message", "content")),
(requests.exceptions.ConnectionError("Connection error")),
(ResourceException),
(requests.exceptions.ConnectionError),
],
ids=[
"auth_error",

View File

@@ -7,7 +7,6 @@ from unittest.mock import MagicMock
from proxmoxer import AuthenticationError
from proxmoxer.core import ResourceException
import pytest
import requests
from requests.exceptions import ConnectTimeout, SSLError
from homeassistant.components.proxmoxve import CONF_HOST, CONF_REALM
@@ -73,14 +72,6 @@ async def test_form(
ConnectTimeout("Connection timed out"),
"connect_timeout",
),
(
ResourceException("404", "status_message", "content"),
"no_nodes_found",
),
(
requests.exceptions.ConnectionError("Connection error"),
"cannot_connect",
),
],
)
async def test_form_exceptions(
@@ -212,10 +203,6 @@ async def test_import_flow(
ResourceException("404", "status_message", "content"),
"no_nodes_found",
),
(
requests.exceptions.ConnectionError("Connection error"),
"cannot_connect",
),
],
)
async def test_import_flow_exceptions(
@@ -322,10 +309,6 @@ async def test_full_flow_reconfigure_match_entries(
ResourceException("404", "status_message", "content"),
"no_nodes_found",
),
(
requests.exceptions.ConnectionError("Connection error"),
"cannot_connect",
),
],
)
async def test_full_flow_reconfigure_exceptions(
@@ -414,10 +397,6 @@ async def test_full_flow_reauth(
ResourceException("404", "status_message", "content"),
"no_nodes_found",
),
(
requests.exceptions.ConnectionError("Connection error"),
"cannot_connect",
),
],
)
async def test_full_flow_reauth_exceptions(

View File

@@ -29,12 +29,8 @@ from homeassistant.components.vacuum import (
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import FakeDevice, set_trait_attributes
@@ -302,12 +298,12 @@ async def test_get_segments(
assert msg["success"]
assert msg["result"] == {
"segments": [
{"id": "0_16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0_17", "name": "Example room 2", "group": "Upstairs"},
{"id": "0_18", "name": "Example room 3", "group": "Upstairs"},
{"id": "1_16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1_17", "name": "Example room 2", "group": "Downstairs"},
{"id": "1_18", "name": "Example room 3", "group": "Downstairs"},
{"id": "0:16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0:17", "name": "Example room 2", "group": "Upstairs"},
{"id": "0:18", "name": "Example room 3", "group": "Upstairs"},
{"id": "1:16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1:17", "name": "Example room 2", "group": "Downstairs"},
{"id": "1:18", "name": "Example room 3", "group": "Downstairs"},
]
}
@@ -342,14 +338,14 @@ async def test_clean_segments(
ENTITY_ID,
VACUUM_DOMAIN,
{
"area_mapping": {"area_1": ["1_16", "1_17"]},
"area_mapping": {"area_1": ["1:16", "1:17"]},
"last_seen_segments": [
{"id": "0_16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0_17", "name": "Example room 2", "group": "Upstairs"},
{"id": "0_18", "name": "Example room 3", "group": "Upstairs"},
{"id": "1_16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1_17", "name": "Example room 2", "group": "Downstairs"},
{"id": "1_18", "name": "Example room 3", "group": "Downstairs"},
{"id": "0:16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0:17", "name": "Example room 2", "group": "Upstairs"},
{"id": "0:18", "name": "Example room 3", "group": "Upstairs"},
{"id": "1:16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1:17", "name": "Example room 2", "group": "Downstairs"},
{"id": "1:18", "name": "Example room 3", "group": "Downstairs"},
],
},
)
@@ -376,18 +372,23 @@ async def test_clean_segments_different_map(
fake_vacuum: FakeDevice,
vacuum_command: Mock,
) -> None:
"""Test that clean_area service silently ignores segments from a non-current map."""
"""Test that clean_area service switches maps when needed."""
entity_registry.async_update_entity_options(
ENTITY_ID,
VACUUM_DOMAIN,
{
# Map 0 (Upstairs) is not the current map (current is map 1, Downstairs),
# so these segments should be silently ignored.
"area_mapping": {"area_1": ["0_16", "0_17"]},
"area_mapping": {
"area_1": ["0:16", "0:17"],
"area_2": ["0:18"],
"area_3": ["1:16"],
},
"last_seen_segments": [
{"id": "0_16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0_17", "name": "Example room 2", "group": "Upstairs"},
{"id": "1_16", "name": "Example room 1", "group": "Downstairs"},
{"id": "0:16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0:17", "name": "Example room 2", "group": "Upstairs"},
{"id": "0:18", "name": "Example room 3", "group": "Upstairs"},
{"id": "1:16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1:17", "name": "Example room 2", "group": "Downstairs"},
{"id": "1:18", "name": "Example room 3", "group": "Downstairs"},
],
},
)
@@ -399,76 +400,132 @@ async def test_clean_segments_different_map(
blocking=True,
)
assert fake_vacuum.v1_properties.maps.set_current_map.call_count == 0
assert vacuum_command.send.call_count == 0
async def test_clean_segments_mixed_maps(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
vacuum_command: Mock,
) -> None:
"""Test that clean_area service cleans only current-map segments when given segments from multiple maps."""
entity_registry.async_update_entity_options(
ENTITY_ID,
VACUUM_DOMAIN,
{
# area_1 maps to segments from both maps; only map 1 (Downstairs) is current.
"area_mapping": {"area_1": ["0_16", "1_17"]},
"last_seen_segments": [
{"id": "0_16", "name": "Example room 1", "group": "Upstairs"},
{"id": "1_17", "name": "Example room 2", "group": "Downstairs"},
],
},
)
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_CLEAN_AREA,
{ATTR_ENTITY_ID: ENTITY_ID, "cleaning_area_id": ["area_1"]},
blocking=True,
)
# Only the segment from the current map (map 1) is cleaned; segment from map 0 is ignored.
assert fake_vacuum.v1_properties.maps.set_current_map.call_count == 1
assert fake_vacuum.v1_properties.maps.set_current_map.call_args == call(0)
assert vacuum_command.send.call_count == 1
assert vacuum_command.send.call_args == call(
RoborockCommand.APP_SEGMENT_CLEAN,
params=[{"segments": [17]}],
params=[{"segments": [16, 17]}],
)
async def test_segments_changed_issue(
async def test_clean_segments_multiple_maps_error(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that clean_area service raises error when segments from multiple maps."""
entity_registry.async_update_entity_options(
ENTITY_ID,
VACUUM_DOMAIN,
{
"area_mapping": {"area_1": ["0:16", "1:17"]},
"last_seen_segments": [
{"id": "0:16", "name": "Example room 1", "group": "Upstairs"},
{"id": "0:17", "name": "Example room 2", "group": "Upstairs"},
{"id": "0:18", "name": "Example room 3", "group": "Upstairs"},
{"id": "1:16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1:17", "name": "Example room 2", "group": "Downstairs"},
{"id": "1:18", "name": "Example room 3", "group": "Downstairs"},
],
},
)
with pytest.raises(
ServiceValidationError,
match="All segments must belong to the same map",
):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_CLEAN_AREA,
{ATTR_ENTITY_ID: ENTITY_ID, "cleaning_area_id": ["area_1"]},
blocking=True,
)
async def test_clean_segments_malformed_id_wrong_parts(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that clean_area raises ServiceValidationError for a segment ID missing the colon separator."""
entity_registry.async_update_entity_options(
ENTITY_ID,
VACUUM_DOMAIN,
{
"area_mapping": {"area_1": ["16"]},
"last_seen_segments": [],
},
)
with pytest.raises(
ServiceValidationError,
match="Invalid segment ID format: 16",
):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_CLEAN_AREA,
{ATTR_ENTITY_ID: ENTITY_ID, "cleaning_area_id": ["area_1"]},
blocking=True,
)
async def test_clean_segments_malformed_id_non_integer(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that clean_area raises ServiceValidationError for a segment ID with non-integer parts."""
entity_registry.async_update_entity_options(
ENTITY_ID,
VACUUM_DOMAIN,
{
"area_mapping": {"area_1": ["abc:16"]},
"last_seen_segments": [],
},
)
with pytest.raises(
ServiceValidationError,
match="Invalid segment ID format: abc:16",
):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_CLEAN_AREA,
{ATTR_ENTITY_ID: ENTITY_ID, "cleaning_area_id": ["area_1"]},
blocking=True,
)
async def test_clean_segments_map_switch_fails(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
fake_vacuum: FakeDevice,
) -> None:
"""Test that a repair issue is created when segments change after area mapping is configured."""
entity_entry = entity_registry.async_get(ENTITY_ID)
assert entity_entry is not None
"""Test that clean_area raises ServiceValidationError when switching to the target map fails."""
fake_vacuum.v1_properties.maps.set_current_map.side_effect = RoborockException()
entity_registry.async_update_entity_options(
ENTITY_ID,
VACUUM_DOMAIN,
{
# The last-seen segments differ from what the vacuum currently reports,
# simulating a remap that added/removed rooms.
"last_seen_segments": [
{"id": "1_16", "name": "Example room 1", "group": "Downstairs"},
{"id": "1_99", "name": "Old room", "group": "Downstairs"},
],
# Map flag 0 (Upstairs) differs from current map flag 1 (Downstairs),
# so a map switch will be attempted and will fail.
"area_mapping": {"area_1": ["0:16"]},
"last_seen_segments": [],
},
)
coordinator = setup_entry.runtime_data.v1[0]
await coordinator.async_refresh()
await hass.async_block_till_done()
issue_id = f"segments_changed_{entity_entry.id}"
issue = ir.async_get(hass).async_get_issue(VACUUM_DOMAIN, issue_id)
assert issue is not None
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_key == "segments_changed"
with pytest.raises(
ServiceValidationError,
match="Error while calling load_multi_map",
):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_CLEAN_AREA,
{ATTR_ENTITY_ID: ENTITY_ID, "cleaning_area_id": ["area_1"]},
blocking=True,
)
# Tests for RoborockQ7Vacuum

Some files were not shown because too many files have changed in this diff Show More