mirror of
https://github.com/home-assistant/core.git
synced 2026-02-25 03:31:15 +01:00
Compare commits
2 Commits
dev
...
number/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6608db6098 | ||
|
|
bfce7a6893 |
20
.github/workflows/builder.yml
vendored
20
.github/workflows/builder.yml
vendored
@@ -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
1
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -148,6 +148,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"number",
|
||||
"person",
|
||||
"scene",
|
||||
"siren",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -173,5 +173,10 @@
|
||||
"set_value": {
|
||||
"service": "mdi:numeric"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"trigger": "mdi:counter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,5 +204,11 @@
|
||||
"name": "Set"
|
||||
}
|
||||
},
|
||||
"title": "Number"
|
||||
"title": "Number",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when a number value changes.",
|
||||
"name": "Number changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
homeassistant/components/number/trigger.py
Normal file
18
homeassistant/components/number/trigger.py
Normal 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
|
||||
4
homeassistant/components/number/triggers.yaml
Normal file
4
homeassistant/components/number/triggers.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
changed:
|
||||
target:
|
||||
entity:
|
||||
domain: number
|
||||
@@ -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*",
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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*",
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyuptimerobot"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyuptimerobot==24.0.1"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/wiz",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pywizlight==0.6.3"]
|
||||
}
|
||||
|
||||
@@ -7766,7 +7766,7 @@
|
||||
},
|
||||
"wiz": {
|
||||
"name": "WiZ",
|
||||
"integration_type": "device",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
|
||||
@@ -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
2
requirements_all.txt
generated
@@ -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
|
||||
|
||||
5
requirements_test_all.txt
generated
5
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
]),
|
||||
'derived': dict({
|
||||
'access_point': True,
|
||||
'fw_major': 8,
|
||||
'fw_major': None,
|
||||
'mac': '**REDACTED**',
|
||||
'mac_interface': 'br0',
|
||||
'mode': 'point_to_point',
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
114
tests/components/number/test_trigger.py
Normal file
114
tests/components/number/test_trigger.py
Normal 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()
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user