mirror of
https://github.com/home-assistant/core.git
synced 2025-09-08 22:31:32 +02:00
Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into target_trigger
This commit is contained in:
10
.github/workflows/builder.yml
vendored
10
.github/workflows/builder.yml
vendored
@@ -190,7 +190,7 @@ jobs:
|
||||
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -330,14 +330,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v3.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: matrix.registry == 'ghcr.io/home-assistant'
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -502,7 +502,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@v1.2.4
|
||||
uses: actions/ai-inference@v1.2.7
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@v1.2.4
|
||||
uses: actions/ai-inference@v1.2.7
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
@@ -120,6 +120,9 @@ class AuthStore:
|
||||
|
||||
new_user = models.User(**kwargs)
|
||||
|
||||
while new_user.id in self._users:
|
||||
new_user = models.User(**kwargs)
|
||||
|
||||
self._users[new_user.id] = new_user
|
||||
|
||||
if credentials is None:
|
||||
|
@@ -6,11 +6,11 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from airos.exceptions import (
|
||||
ConnectionAuthenticationError,
|
||||
ConnectionSetupError,
|
||||
DataMissingError,
|
||||
DeviceConnectionError,
|
||||
KeyDataMissingError,
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSKeyDataMissingError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
airos_data = await airos_device.status()
|
||||
|
||||
except (
|
||||
ConnectionSetupError,
|
||||
DeviceConnectionError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
):
|
||||
errors["base"] = "cannot_connect"
|
||||
except (ConnectionAuthenticationError, DataMissingError):
|
||||
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
|
||||
errors["base"] = "invalid_auth"
|
||||
except KeyDataMissingError:
|
||||
except AirOSKeyDataMissingError:
|
||||
errors["base"] = "key_data_missing"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
|
@@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from airos.airos8 import AirOS, AirOSData
|
||||
from airos.exceptions import (
|
||||
ConnectionAuthenticationError,
|
||||
ConnectionSetupError,
|
||||
DataMissingError,
|
||||
DeviceConnectionError,
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -47,18 +47,22 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
|
||||
try:
|
||||
await self.airos_device.login()
|
||||
return await self.airos_device.status()
|
||||
except (ConnectionAuthenticationError,) as err:
|
||||
except (AirOSConnectionAuthenticationError,) as err:
|
||||
_LOGGER.exception("Error authenticating with airOS device")
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err:
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
TimeoutError,
|
||||
) as err:
|
||||
_LOGGER.error("Error connecting to airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except (DataMissingError,) as err:
|
||||
except (AirOSDataMissingError,) as err:
|
||||
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airos==0.2.1"]
|
||||
"requirements": ["airos==0.2.4"]
|
||||
}
|
||||
|
@@ -69,13 +69,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
translation_key="wireless_essid",
|
||||
value_fn=lambda data: data.wireless.essid,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_mode",
|
||||
translation_key="wireless_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
|
||||
options=WIRELESS_MODE_OPTIONS,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_antenna_gain",
|
||||
translation_key="wireless_antenna_gain",
|
||||
|
@@ -43,13 +43,6 @@
|
||||
"wireless_essid": {
|
||||
"name": "Wireless SSID"
|
||||
},
|
||||
"wireless_mode": {
|
||||
"name": "Wireless mode",
|
||||
"state": {
|
||||
"ap_ptp": "Access point",
|
||||
"sta_ptp": "Station"
|
||||
}
|
||||
},
|
||||
"wireless_antenna_gain": {
|
||||
"name": "Antenna gain"
|
||||
},
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/caldav",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["caldav", "vobject"],
|
||||
"requirements": ["caldav==1.6.0", "icalendar==6.1.0"]
|
||||
"requirements": ["caldav==1.6.0", "icalendar==6.3.1"]
|
||||
}
|
||||
|
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.110.1"],
|
||||
"requirements": ["hass-nabucasa==0.111.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -18,6 +18,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# If path is relative, we assume relative to Home Assistant config dir
|
||||
if not os.path.isabs(download_path):
|
||||
download_path = hass.config.path(download_path)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path}
|
||||
)
|
||||
|
||||
if not await hass.async_add_executor_job(os.path.isdir, download_path):
|
||||
_LOGGER.error(
|
||||
|
@@ -11,6 +11,7 @@ import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
@@ -34,24 +35,33 @@ def download_file(service: ServiceCall) -> None:
|
||||
|
||||
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
download_path = entry.data[CONF_DOWNLOAD_DIR]
|
||||
url: str = service.data[ATTR_URL]
|
||||
subdir: str | None = service.data.get(ATTR_SUBDIR)
|
||||
target_filename: str | None = service.data.get(ATTR_FILENAME)
|
||||
overwrite: bool = service.data[ATTR_OVERWRITE]
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
try:
|
||||
raise_if_invalid_path(subdir)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="subdir_invalid",
|
||||
translation_placeholders={"subdir": subdir},
|
||||
) from err
|
||||
if os.path.isabs(subdir):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="subdir_not_relative",
|
||||
translation_placeholders={"subdir": subdir},
|
||||
)
|
||||
|
||||
def do_download() -> None:
|
||||
"""Download the file."""
|
||||
final_path = None
|
||||
filename = target_filename
|
||||
try:
|
||||
url = service.data[ATTR_URL]
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
overwrite = service.data.get(ATTR_OVERWRITE)
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
raise_if_invalid_path(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
|
@@ -12,6 +12,14 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"subdir_invalid": {
|
||||
"message": "Invalid subdirectory, got: {subdir}"
|
||||
},
|
||||
"subdir_not_relative": {
|
||||
"message": "Subdirectory must be relative, got: {subdir}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"download_file": {
|
||||
"name": "Download file",
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"]
|
||||
}
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250731.0"]
|
||||
"requirements": ["home-assistant-frontend==20250805.0"]
|
||||
}
|
||||
|
@@ -123,10 +123,10 @@
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
"user": "Add AI task",
|
||||
"reconfigure": "Reconfigure AI task"
|
||||
},
|
||||
"entry_type": "Generate data with AI service",
|
||||
"entry_type": "AI task",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
|
@@ -226,6 +226,10 @@
|
||||
"unsupported_virtualization_image": {
|
||||
"title": "Unsupported system - Incorrect OS image for virtualization",
|
||||
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_os_version": {
|
||||
"title": "Unsupported system - Home Assistant OS version",
|
||||
"description": "System is unsupported because the Home Assistant OS version in use is not supported. Use the link to learn more and how to fix this."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
@@ -21,6 +21,20 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_reset_cutting_blade_usage_time(
|
||||
session: AutomowerSession,
|
||||
mower_id: str,
|
||||
) -> None:
|
||||
"""Reset cutting blade usage time."""
|
||||
await session.commands.reset_cutting_blade_usage_time(mower_id)
|
||||
|
||||
|
||||
def reset_cutting_blade_usage_time_availability(data: MowerAttributes) -> bool:
|
||||
"""Return True if blade usage time is greater than 0."""
|
||||
value = data.statistics.cutting_blade_usage_time
|
||||
return value is not None and value > 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AutomowerButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Automower button entities."""
|
||||
@@ -28,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription):
|
||||
available_fn: Callable[[MowerAttributes], bool] = lambda _: True
|
||||
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
|
||||
press_fn: Callable[[AutomowerSession, str], Awaitable[Any]]
|
||||
poll_after_sending: bool = False
|
||||
|
||||
|
||||
MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
|
||||
@@ -43,6 +58,14 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
|
||||
translation_key="sync_clock",
|
||||
press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id),
|
||||
),
|
||||
AutomowerButtonEntityDescription(
|
||||
key="reset_cutting_blade_usage_time",
|
||||
translation_key="reset_cutting_blade_usage_time",
|
||||
available_fn=reset_cutting_blade_usage_time_availability,
|
||||
exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None,
|
||||
press_fn=async_reset_cutting_blade_usage_time,
|
||||
poll_after_sending=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,3 +116,5 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
|
||||
async def async_press(self) -> None:
|
||||
"""Send a command to the mower."""
|
||||
await self.entity_description.press_fn(self.coordinator.api, self.mower_id)
|
||||
if self.entity_description.poll_after_sending:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
@@ -14,7 +14,7 @@ from aioautomower.exceptions import (
|
||||
HusqvarnaTimeoutError,
|
||||
HusqvarnaWSServerHandshakeError,
|
||||
)
|
||||
from aioautomower.model import MowerDictionary
|
||||
from aioautomower.model import MowerDictionary, MowerStates
|
||||
from aioautomower.session import AutomowerSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_WS_RECONNECT_TIME = 600
|
||||
SCAN_INTERVAL = timedelta(minutes=8)
|
||||
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
||||
|
||||
PONG_TIMEOUT = timedelta(seconds=90)
|
||||
PING_INTERVAL = timedelta(seconds=10)
|
||||
PING_TIMEOUT = timedelta(seconds=5)
|
||||
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
|
||||
|
||||
|
||||
@@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
|
||||
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
|
||||
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
|
||||
self.pong: datetime | None = None
|
||||
self.websocket_alive: bool = False
|
||||
self._watchdog_task: asyncio.Task | None = None
|
||||
|
||||
@override
|
||||
@callback
|
||||
@@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
await self.api.connect()
|
||||
self.api.register_data_callback(self.handle_websocket_updates)
|
||||
self.ws_connected = True
|
||||
|
||||
def start_watchdog() -> None:
|
||||
if self._watchdog_task is not None and not self._watchdog_task.done():
|
||||
_LOGGER.debug("Cancelling previous watchdog task")
|
||||
self._watchdog_task.cancel()
|
||||
self._watchdog_task = self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._pong_watchdog(),
|
||||
"websocket_watchdog",
|
||||
)
|
||||
|
||||
self.api.register_ws_ready_callback(start_watchdog)
|
||||
try:
|
||||
data = await self.api.get_status()
|
||||
except ApiError as err:
|
||||
@@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
mower_data.capabilities.work_areas for mower_data in self.data.values()
|
||||
):
|
||||
self._async_add_remove_work_areas()
|
||||
if (
|
||||
not self._should_poll()
|
||||
and self.update_interval is not None
|
||||
and self.websocket_alive
|
||||
):
|
||||
_LOGGER.debug("All mowers inactive and websocket alive: stop polling")
|
||||
self.update_interval = None
|
||||
if self.update_interval is None and self._should_poll():
|
||||
_LOGGER.debug(
|
||||
"Polling re-enabled via WebSocket: at least one mower active"
|
||||
)
|
||||
self.update_interval = SCAN_INTERVAL
|
||||
self.hass.async_create_task(self.async_request_refresh())
|
||||
|
||||
@callback
|
||||
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
|
||||
@@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
"reconnect_task",
|
||||
)
|
||||
|
||||
def _should_poll(self) -> bool:
|
||||
"""Return True if at least one mower is connected and at least one is not OFF."""
|
||||
return any(mower.metadata.connected for mower in self.data.values()) and any(
|
||||
mower.mower.state != MowerStates.OFF for mower in self.data.values()
|
||||
)
|
||||
|
||||
async def _pong_watchdog(self) -> None:
|
||||
_LOGGER.debug("Watchdog started")
|
||||
try:
|
||||
while True:
|
||||
_LOGGER.debug("Sending ping")
|
||||
self.websocket_alive = await self.api.send_empty_message()
|
||||
_LOGGER.debug("Ping result: %s", self.websocket_alive)
|
||||
|
||||
await asyncio.sleep(60)
|
||||
_LOGGER.debug("Websocket alive %s", self.websocket_alive)
|
||||
if not self.websocket_alive:
|
||||
_LOGGER.debug("No pong received → restart polling")
|
||||
if self.update_interval is None:
|
||||
self.update_interval = SCAN_INTERVAL
|
||||
await self.async_request_refresh()
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Watchdog cancelled")
|
||||
|
||||
def _async_add_remove_devices(self) -> None:
|
||||
"""Add new devices and remove orphaned devices from the registry."""
|
||||
current_devices = set(self.data)
|
||||
|
@@ -8,6 +8,9 @@
|
||||
"button": {
|
||||
"sync_clock": {
|
||||
"default": "mdi:clock-check-outline"
|
||||
},
|
||||
"reset_cutting_blade_usage_time": {
|
||||
"default": "mdi:saw-blade"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.1.1"]
|
||||
"requirements": ["aioautomower==2.1.2"]
|
||||
}
|
||||
|
@@ -53,6 +53,9 @@
|
||||
},
|
||||
"sync_clock": {
|
||||
"name": "Sync clock"
|
||||
},
|
||||
"reset_cutting_blade_usage_time": {
|
||||
"name": "Reset cutting blade usage time"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
|
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.8.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.7.23.50952"
|
||||
"knx-frontend==2025.8.4.154919"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -285,7 +285,9 @@ DISCOVERY_SCHEMAS = [
|
||||
native_min_value=0.5,
|
||||
native_step=0.5,
|
||||
device_to_ha=(
|
||||
lambda x: None if x is None else x / 2 # Matter range (1-200)
|
||||
lambda x: None
|
||||
if x is None
|
||||
else min(x, 200) / 2 # Matter range (1-200, capped at 200)
|
||||
),
|
||||
ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0%
|
||||
mode=NumberMode.SLIDER,
|
||||
|
@@ -130,6 +130,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
|
||||
list_id=self._shopping_list_id,
|
||||
note=item.summary.strip() if item.summary else item.summary,
|
||||
position=position,
|
||||
quantity=0.0,
|
||||
)
|
||||
try:
|
||||
await self.coordinator.client.add_shopping_item(new_shopping_item)
|
||||
|
@@ -11,7 +11,7 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"mqtt": {
|
||||
"_": {
|
||||
"trigger": "mdi:swap-horizontal"
|
||||
}
|
||||
}
|
||||
|
@@ -1285,7 +1285,7 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"mqtt": {
|
||||
"_": {
|
||||
"name": "MQTT",
|
||||
"description": "When a specific message is received on a given MQTT topic.",
|
||||
"description_configured": "When an MQTT message has been received",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Describes the format for MQTT triggers
|
||||
|
||||
mqtt:
|
||||
_:
|
||||
fields:
|
||||
payload:
|
||||
example: "on"
|
||||
|
@@ -58,10 +58,10 @@
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
"user": "Add AI task",
|
||||
"reconfigure": "Reconfigure AI task"
|
||||
},
|
||||
"entry_type": "Generate data with AI service",
|
||||
"entry_type": "AI task",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
|
@@ -52,9 +52,9 @@
|
||||
}
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add Generate data with AI service"
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"entry_type": "Generate data with AI service",
|
||||
"entry_type": "AI task",
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
|
@@ -73,10 +73,10 @@
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
"user": "Add AI task",
|
||||
"reconfigure": "Reconfigure AI task"
|
||||
},
|
||||
"entry_type": "Generate data with AI service",
|
||||
"entry_type": "AI task",
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
|
@@ -422,9 +422,7 @@ class ReolinkVODMediaSource(MediaSource):
|
||||
file_name = f"{file.start_time.time()} {file.duration}"
|
||||
if file.triggers != file.triggers.NONE:
|
||||
file_name += " " + " ".join(
|
||||
str(trigger.name).title()
|
||||
for trigger in file.triggers
|
||||
if trigger != trigger.NONE
|
||||
str(trigger.name).title() for trigger in file.triggers
|
||||
)
|
||||
|
||||
children.append(
|
||||
|
@@ -116,6 +116,7 @@ NUMBER_ENTITIES = (
|
||||
cmd_id=[289, 438],
|
||||
translation_key="floodlight_brightness",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
native_step=1,
|
||||
native_min_value=1,
|
||||
native_max_value=100,
|
||||
@@ -407,8 +408,8 @@ NUMBER_ENTITIES = (
|
||||
key="auto_track_limit_left",
|
||||
cmd_key="GetPtzTraceSection",
|
||||
translation_key="auto_track_limit_left",
|
||||
mode=NumberMode.SLIDER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
native_step=1,
|
||||
native_min_value=-1,
|
||||
native_max_value=2700,
|
||||
@@ -420,8 +421,8 @@ NUMBER_ENTITIES = (
|
||||
key="auto_track_limit_right",
|
||||
cmd_key="GetPtzTraceSection",
|
||||
translation_key="auto_track_limit_right",
|
||||
mode=NumberMode.SLIDER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
native_step=1,
|
||||
native_min_value=-1,
|
||||
native_max_value=2700,
|
||||
@@ -435,6 +436,7 @@ NUMBER_ENTITIES = (
|
||||
translation_key="auto_track_disappear_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_registry_enabled_default=False,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=1,
|
||||
@@ -451,6 +453,7 @@ NUMBER_ENTITIES = (
|
||||
translation_key="auto_track_stop_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_registry_enabled_default=False,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=1,
|
||||
|
@@ -440,6 +440,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_battery_level": {
|
||||
"title": "Deprecated battery level option in {entity_name}",
|
||||
"description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name})."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"alarm_control_panel": {
|
||||
|
@@ -34,11 +34,16 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import config_validation as cv, template
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
issue_registry as ir,
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -188,6 +193,26 @@ def async_create_preview_vacuum(
|
||||
)
|
||||
|
||||
|
||||
def create_issue(
|
||||
hass: HomeAssistant, supported_features: int, name: str, entity_id: str
|
||||
) -> None:
|
||||
"""Create the battery_level issue."""
|
||||
if supported_features & VacuumEntityFeature.BATTERY:
|
||||
key = "deprecated_battery_level"
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"{key}_{entity_id}",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=key,
|
||||
translation_placeholders={
|
||||
"entity_name": name,
|
||||
"entity_id": entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
|
||||
"""Representation of a template vacuum features."""
|
||||
|
||||
@@ -369,6 +394,16 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum):
|
||||
self.add_script(action_id, action_config, name, DOMAIN)
|
||||
self._attr_supported_features |= supported_feature
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
create_issue(
|
||||
self.hass,
|
||||
self._attr_supported_features,
|
||||
self._attr_name or DEFAULT_NAME,
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_setup_templates(self) -> None:
|
||||
"""Set up templates."""
|
||||
@@ -434,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
|
||||
self._to_render_simple.append(key)
|
||||
self._parse_result.add(key)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
create_issue(
|
||||
self.hass,
|
||||
self._attr_supported_features,
|
||||
self._attr_name or DEFAULT_NAME,
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle update of the data."""
|
||||
|
@@ -314,6 +314,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
|
||||
),
|
||||
TAMPER_BINARY_SENSOR,
|
||||
),
|
||||
# Zigbee gateway
|
||||
# Undocumented
|
||||
"wg2": (
|
||||
TuyaBinarySensorEntityDescription(
|
||||
key=DPCode.MASTER_STATE,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
on_value="alarm",
|
||||
),
|
||||
),
|
||||
# Thermostatic Radiator Valve
|
||||
# Not documented
|
||||
"wkf": (
|
||||
|
@@ -109,6 +109,7 @@ class DPCode(StrEnum):
|
||||
ANION = "anion" # Ionizer unit
|
||||
ARM_DOWN_PERCENT = "arm_down_percent"
|
||||
ARM_UP_PERCENT = "arm_up_percent"
|
||||
ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API
|
||||
BASIC_ANTI_FLICKER = "basic_anti_flicker"
|
||||
BASIC_DEVICE_VOLUME = "basic_device_volume"
|
||||
BASIC_FLIP = "basic_flip"
|
||||
@@ -215,6 +216,10 @@ class DPCode(StrEnum):
|
||||
HUMIDITY = "humidity" # Humidity
|
||||
HUMIDITY_CURRENT = "humidity_current" # Current humidity
|
||||
HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity
|
||||
HUMIDITY_OUTDOOR = "humidity_outdoor" # Outdoor humidity
|
||||
HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" # Outdoor humidity
|
||||
HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" # Outdoor humidity
|
||||
HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity
|
||||
HUMIDITY_SET = "humidity_set" # Humidity setting
|
||||
HUMIDITY_VALUE = "humidity_value" # Humidity
|
||||
IPC_WORK_MODE = "ipc_work_mode"
|
||||
@@ -360,6 +365,15 @@ class DPCode(StrEnum):
|
||||
TEMP_CURRENT_EXTERNAL = (
|
||||
"temp_current_external" # Current external temperature in Celsius
|
||||
)
|
||||
TEMP_CURRENT_EXTERNAL_1 = (
|
||||
"temp_current_external_1" # Current external temperature in Celsius
|
||||
)
|
||||
TEMP_CURRENT_EXTERNAL_2 = (
|
||||
"temp_current_external_2" # Current external temperature in Celsius
|
||||
)
|
||||
TEMP_CURRENT_EXTERNAL_3 = (
|
||||
"temp_current_external_3" # Current external temperature in Celsius
|
||||
)
|
||||
TEMP_CURRENT_EXTERNAL_F = (
|
||||
"temp_current_external_f" # Current external temperature in Fahrenheit
|
||||
)
|
||||
@@ -405,6 +419,7 @@ class DPCode(StrEnum):
|
||||
WINDOW_CHECK = "window_check"
|
||||
WINDOW_STATE = "window_state"
|
||||
WINDSPEED = "windspeed"
|
||||
WINDSPEED_AVG = "windspeed_avg"
|
||||
WIRELESS_BATTERYLOCK = "wireless_batterylock"
|
||||
WIRELESS_ELECTRICITY = "wireless_electricity"
|
||||
WORK_MODE = "work_mode" # Working mode
|
||||
|
@@ -267,7 +267,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
return int(self._speed.remap_value_to(value, 1, 100))
|
||||
|
||||
if self._speeds is not None:
|
||||
if (value := self.device.status.get(self._speeds.dpcode)) is None:
|
||||
if (
|
||||
value := self.device.status.get(self._speeds.dpcode)
|
||||
) is None or value not in self._speeds.range:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(self._speeds.range, value)
|
||||
|
||||
|
@@ -846,6 +846,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.TEMP_CURRENT_EXTERNAL_1,
|
||||
translation_key="indexed_temperature_external",
|
||||
translation_placeholders={"index": "1"},
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.TEMP_CURRENT_EXTERNAL_2,
|
||||
translation_key="indexed_temperature_external",
|
||||
translation_placeholders={"index": "2"},
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.TEMP_CURRENT_EXTERNAL_3,
|
||||
translation_key="indexed_temperature_external",
|
||||
translation_placeholders={"index": "3"},
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.VA_HUMIDITY,
|
||||
translation_key="humidity",
|
||||
@@ -858,12 +879,51 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.HUMIDITY_OUTDOOR,
|
||||
translation_key="humidity_outdoor",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.HUMIDITY_OUTDOOR_1,
|
||||
translation_key="indexed_humidity_outdoor",
|
||||
translation_placeholders={"index": "1"},
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.HUMIDITY_OUTDOOR_2,
|
||||
translation_key="indexed_humidity_outdoor",
|
||||
translation_placeholders={"index": "2"},
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.HUMIDITY_OUTDOOR_3,
|
||||
translation_key="indexed_humidity_outdoor",
|
||||
translation_placeholders={"index": "3"},
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.ATMOSPHERIC_PRESSTURE,
|
||||
translation_key="air_pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.BRIGHT_VALUE,
|
||||
translation_key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.WINDSPEED_AVG,
|
||||
translation_key="wind_speed",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
*BATTERY_SENSORS,
|
||||
),
|
||||
# Gas Detector
|
||||
|
@@ -502,9 +502,21 @@
|
||||
"temperature_external": {
|
||||
"name": "Probe temperature"
|
||||
},
|
||||
"indexed_temperature_external": {
|
||||
"name": "Probe temperature channel {index}"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "[%key:component::sensor::entity_component::humidity::name%]"
|
||||
},
|
||||
"humidity_outdoor": {
|
||||
"name": "Outdoor humidity"
|
||||
},
|
||||
"indexed_humidity_outdoor": {
|
||||
"name": "Outdoor humidity channel {index}"
|
||||
},
|
||||
"air_pressure": {
|
||||
"name": "Air pressure"
|
||||
},
|
||||
"pm25": {
|
||||
"name": "[%key:component::sensor::entity_component::pm25::name%]"
|
||||
},
|
||||
|
@@ -79,6 +79,8 @@ DEFAULT_NAME = "Vacuum cleaner robot"
|
||||
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1")
|
||||
_DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1")
|
||||
|
||||
_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",)
|
||||
|
||||
|
||||
class VacuumEntityFeature(IntFlag):
|
||||
"""Supported features of the vacuum entity."""
|
||||
@@ -321,7 +323,11 @@ class StateVacuumEntity(
|
||||
|
||||
Integrations should implement a sensor instead.
|
||||
"""
|
||||
if self.platform:
|
||||
if (
|
||||
self.platform
|
||||
and self.platform.platform_name
|
||||
not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS
|
||||
):
|
||||
# Don't report usage until after entity added to hass, after init
|
||||
report_usage(
|
||||
f"is setting the {property} which has been deprecated."
|
||||
@@ -341,7 +347,11 @@ class StateVacuumEntity(
|
||||
Integrations should remove the battery supported feature when migrating
|
||||
battery level and icon to a sensor.
|
||||
"""
|
||||
if self.platform:
|
||||
if (
|
||||
self.platform
|
||||
and self.platform.platform_name
|
||||
not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS
|
||||
):
|
||||
# Don't report usage until after entity added to hass, after init
|
||||
report_usage(
|
||||
f"is setting the battery supported feature which has been deprecated."
|
||||
|
@@ -13,6 +13,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/wyoming",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["wyoming==1.7.1"],
|
||||
"requirements": ["wyoming==1.7.2"],
|
||||
"zeroconf": ["_wyoming._tcp.local."]
|
||||
}
|
||||
|
@@ -509,7 +509,7 @@ class ControllerEvents:
|
||||
)
|
||||
)
|
||||
|
||||
await self.async_check_preprovisioned_device(node)
|
||||
await self.async_check_pre_provisioned_device(node)
|
||||
|
||||
if node.is_controller_node:
|
||||
# Create a controller status sensor for each device
|
||||
@@ -637,8 +637,8 @@ class ControllerEvents:
|
||||
f"{DOMAIN}.identify_controller.{dev_id[1]}",
|
||||
)
|
||||
|
||||
async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None:
|
||||
"""Check if the node was preprovisioned and update the device registry."""
|
||||
async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None:
|
||||
"""Check if the node was pre-provisioned and update the device registry."""
|
||||
provisioning_entry = (
|
||||
await self.driver_events.driver.controller.async_get_provisioning_entry(
|
||||
node.node_id
|
||||
@@ -648,29 +648,37 @@ class ControllerEvents:
|
||||
provisioning_entry
|
||||
and provisioning_entry.additional_properties
|
||||
and "device_id" in provisioning_entry.additional_properties
|
||||
):
|
||||
preprovisioned_device = self.dev_reg.async_get(
|
||||
provisioning_entry.additional_properties["device_id"]
|
||||
and (
|
||||
pre_provisioned_device := self.dev_reg.async_get(
|
||||
provisioning_entry.additional_properties["device_id"]
|
||||
)
|
||||
)
|
||||
and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}"))
|
||||
in pre_provisioned_device.identifiers
|
||||
):
|
||||
driver = self.driver_events.driver
|
||||
device_id = get_device_id(driver, node)
|
||||
device_id_ext = get_device_id_ext(driver, node)
|
||||
new_identifiers = pre_provisioned_device.identifiers.copy()
|
||||
new_identifiers.remove(dsk_identifier)
|
||||
new_identifiers.add(device_id)
|
||||
if device_id_ext:
|
||||
new_identifiers.add(device_id_ext)
|
||||
|
||||
if preprovisioned_device:
|
||||
dsk = provisioning_entry.dsk
|
||||
dsk_identifier = (DOMAIN, f"provision_{dsk}")
|
||||
|
||||
# If the pre-provisioned device has the DSK identifier, remove it
|
||||
if dsk_identifier in preprovisioned_device.identifiers:
|
||||
driver = self.driver_events.driver
|
||||
device_id = get_device_id(driver, node)
|
||||
device_id_ext = get_device_id_ext(driver, node)
|
||||
new_identifiers = preprovisioned_device.identifiers.copy()
|
||||
new_identifiers.remove(dsk_identifier)
|
||||
new_identifiers.add(device_id)
|
||||
if device_id_ext:
|
||||
new_identifiers.add(device_id_ext)
|
||||
self.dev_reg.async_update_device(
|
||||
preprovisioned_device.id,
|
||||
new_identifiers=new_identifiers,
|
||||
)
|
||||
if self.dev_reg.async_get_device(identifiers=new_identifiers):
|
||||
# If a device entry is registered with the node ID based identifiers,
|
||||
# just remove the device entry with the DSK identifier.
|
||||
self.dev_reg.async_update_device(
|
||||
pre_provisioned_device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
else:
|
||||
# Add the node ID based identifiers to the device entry
|
||||
# with the DSK identifier and remove the DSK identifier.
|
||||
self.dev_reg.async_update_device(
|
||||
pre_provisioned_device.id,
|
||||
new_identifiers=new_identifiers,
|
||||
)
|
||||
|
||||
async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
|
||||
"""Register node in dev reg."""
|
||||
|
@@ -8,8 +8,8 @@ from homeassistant.helpers.trigger import Trigger
|
||||
from .triggers import event, value_updated
|
||||
|
||||
TRIGGERS = {
|
||||
event.PLATFORM_TYPE: event.EventTrigger,
|
||||
value_updated.PLATFORM_TYPE: value_updated.ValueUpdatedTrigger,
|
||||
event.RELATIVE_PLATFORM_TYPE: event.EventTrigger,
|
||||
value_updated.RELATIVE_PLATFORM_TYPE: value_updated.ValueUpdatedTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
@@ -34,8 +34,11 @@ from ..helpers import (
|
||||
)
|
||||
from .trigger_helpers import async_bypass_dynamic_config_validation
|
||||
|
||||
# Relative platform type should be <SUBMODULE_NAME>
|
||||
RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}"
|
||||
|
||||
# Platform type should be <DOMAIN>.<SUBMODULE_NAME>
|
||||
PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}"
|
||||
PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}"
|
||||
|
||||
|
||||
def validate_non_node_event_source(obj: dict) -> dict:
|
||||
|
@@ -37,8 +37,11 @@ from ..const import (
|
||||
from ..helpers import async_get_nodes_from_targets, get_device_id
|
||||
from .trigger_helpers import async_bypass_dynamic_config_validation
|
||||
|
||||
# Relative platform type should be <SUBMODULE_NAME>
|
||||
RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}"
|
||||
|
||||
# Platform type should be <DOMAIN>.<SUBMODULE_NAME>
|
||||
PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}"
|
||||
PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}"
|
||||
|
||||
ATTR_FROM = "from"
|
||||
ATTR_TO = "to"
|
||||
|
21
homeassistant/helpers/automation.py
Normal file
21
homeassistant/helpers/automation.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Helpers for automation."""
|
||||
|
||||
|
||||
def get_absolute_description_key(domain: str, key: str) -> str:
|
||||
"""Return the absolute description key."""
|
||||
if not key.startswith("_"):
|
||||
return f"{domain}.{key}"
|
||||
key = key[1:] # Remove leading underscore
|
||||
if not key:
|
||||
return domain
|
||||
return key
|
||||
|
||||
|
||||
def get_relative_description_key(domain: str, key: str) -> str:
|
||||
"""Return the relative description key."""
|
||||
platform, *subtype = key.split(".", 1)
|
||||
if platform != domain:
|
||||
return f"_{key}"
|
||||
if not subtype:
|
||||
return "_"
|
||||
return subtype[0]
|
@@ -644,6 +644,13 @@ def slug(value: Any) -> str:
|
||||
raise vol.Invalid(f"invalid slug {value} (try {slg})")
|
||||
|
||||
|
||||
def underscore_slug(value: Any) -> str:
|
||||
"""Validate value is a valid slug, possibly starting with an underscore."""
|
||||
if value.startswith("_"):
|
||||
return f"_{slug(value[1:])}"
|
||||
return slug(value)
|
||||
|
||||
|
||||
def schema_with_slug_keys(
|
||||
value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug
|
||||
) -> Callable:
|
||||
|
@@ -40,9 +40,9 @@ from homeassistant.loader import (
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.yaml import load_yaml_dict
|
||||
from homeassistant.util.yaml.loader import JSON_TYPE
|
||||
|
||||
from . import config_validation as cv, selector
|
||||
from .automation import get_absolute_description_key, get_relative_description_key
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import TargetSelector
|
||||
from .template import Template
|
||||
@@ -100,7 +100,7 @@ def starts_with_dot(key: str) -> str:
|
||||
_TRIGGERS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Remove(vol.All(str, starts_with_dot)): object,
|
||||
cv.slug: vol.Any(None, _TRIGGER_SCHEMA),
|
||||
cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -139,6 +139,7 @@ async def _register_trigger_platform(
|
||||
|
||||
if hasattr(platform, "async_get_triggers"):
|
||||
for trigger_key in await platform.async_get_triggers(hass):
|
||||
trigger_key = get_absolute_description_key(integration_domain, trigger_key)
|
||||
hass.data[TRIGGERS][trigger_key] = integration_domain
|
||||
new_triggers.add(trigger_key)
|
||||
elif hasattr(platform, "async_validate_trigger_config") or hasattr(
|
||||
@@ -357,9 +358,8 @@ class PluggableAction:
|
||||
|
||||
|
||||
async def _async_get_trigger_platform(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> TriggerProtocol:
|
||||
trigger_key: str = config[CONF_PLATFORM]
|
||||
hass: HomeAssistant, trigger_key: str
|
||||
) -> tuple[str, TriggerProtocol]:
|
||||
platform_and_sub_type = trigger_key.split(".")
|
||||
platform = platform_and_sub_type[0]
|
||||
platform = _PLATFORM_ALIASES.get(platform, platform)
|
||||
@@ -368,7 +368,7 @@ async def _async_get_trigger_platform(
|
||||
except IntegrationNotFound:
|
||||
raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None
|
||||
try:
|
||||
return await integration.async_get_platform("trigger")
|
||||
return platform, await integration.async_get_platform("trigger")
|
||||
except ImportError:
|
||||
raise vol.Invalid(
|
||||
f"Integration '{platform}' does not provide trigger support"
|
||||
@@ -381,11 +381,14 @@ async def async_validate_trigger_config(
|
||||
"""Validate triggers."""
|
||||
config = []
|
||||
for conf in trigger_config:
|
||||
platform = await _async_get_trigger_platform(hass, conf)
|
||||
trigger_key: str = conf[CONF_PLATFORM]
|
||||
platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key)
|
||||
if hasattr(platform, "async_get_triggers"):
|
||||
trigger_descriptors = await platform.async_get_triggers(hass)
|
||||
trigger_key: str = conf[CONF_PLATFORM]
|
||||
if not (trigger := trigger_descriptors.get(trigger_key)):
|
||||
relative_trigger_key = get_relative_description_key(
|
||||
platform_domain, trigger_key
|
||||
)
|
||||
if not (trigger := trigger_descriptors.get(relative_trigger_key)):
|
||||
raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified")
|
||||
conf = await trigger.async_validate_trigger_config(hass, conf)
|
||||
elif hasattr(platform, "async_validate_trigger_config"):
|
||||
@@ -471,7 +474,8 @@ async def async_initialize_triggers(
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
platform = await _async_get_trigger_platform(hass, conf)
|
||||
trigger_key: str = conf[CONF_PLATFORM]
|
||||
platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key)
|
||||
trigger_id = conf.get(CONF_ID, f"{idx}")
|
||||
trigger_idx = f"{idx}"
|
||||
trigger_alias = conf.get(CONF_ALIAS)
|
||||
@@ -487,7 +491,10 @@ async def async_initialize_triggers(
|
||||
action_wrapper = _trigger_action_wrapper(hass, action, conf)
|
||||
if hasattr(platform, "async_get_triggers"):
|
||||
trigger_descriptors = await platform.async_get_triggers(hass)
|
||||
trigger = trigger_descriptors[conf[CONF_PLATFORM]](hass, conf)
|
||||
relative_trigger_key = get_relative_description_key(
|
||||
platform_domain, trigger_key
|
||||
)
|
||||
trigger = trigger_descriptors[relative_trigger_key](hass, conf)
|
||||
coro = trigger.async_attach_trigger(action_wrapper, info)
|
||||
else:
|
||||
coro = platform.async_attach_trigger(hass, conf, action_wrapper, info)
|
||||
@@ -525,11 +532,11 @@ async def async_initialize_triggers(
|
||||
return remove_triggers
|
||||
|
||||
|
||||
def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
|
||||
def _load_triggers_file(integration: Integration) -> dict[str, Any]:
|
||||
"""Load triggers file for an integration."""
|
||||
try:
|
||||
return cast(
|
||||
JSON_TYPE,
|
||||
dict[str, Any],
|
||||
_TRIGGERS_SCHEMA(
|
||||
load_yaml_dict(str(integration.file_path / "triggers.yaml"))
|
||||
),
|
||||
@@ -549,11 +556,14 @@ def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_T
|
||||
|
||||
|
||||
def _load_triggers_files(
|
||||
hass: HomeAssistant, integrations: Iterable[Integration]
|
||||
) -> dict[str, JSON_TYPE]:
|
||||
integrations: Iterable[Integration],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Load trigger files for multiple integrations."""
|
||||
return {
|
||||
integration.domain: _load_triggers_file(hass, integration)
|
||||
integration.domain: {
|
||||
get_absolute_description_key(integration.domain, key): value
|
||||
for key, value in _load_triggers_file(integration).items()
|
||||
}
|
||||
for integration in integrations
|
||||
}
|
||||
|
||||
@@ -574,7 +584,7 @@ async def async_get_all_descriptions(
|
||||
return descriptions_cache
|
||||
|
||||
# Files we loaded for missing descriptions
|
||||
new_triggers_descriptions: dict[str, JSON_TYPE] = {}
|
||||
new_triggers_descriptions: dict[str, dict[str, Any]] = {}
|
||||
# We try to avoid making a copy in the event the cache is good,
|
||||
# but now we must make a copy in case new triggers get added
|
||||
# while we are loading the missing ones so we do not
|
||||
@@ -601,7 +611,7 @@ async def async_get_all_descriptions(
|
||||
|
||||
if integrations:
|
||||
new_triggers_descriptions = await hass.async_add_executor_job(
|
||||
_load_triggers_files, hass, integrations
|
||||
_load_triggers_files, integrations
|
||||
)
|
||||
|
||||
# Make a copy of the old cache and add missing descriptions to it
|
||||
@@ -610,7 +620,7 @@ async def async_get_all_descriptions(
|
||||
domain = triggers[missing_trigger]
|
||||
|
||||
if (
|
||||
yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr]
|
||||
yaml_description := new_triggers_descriptions.get(domain, {}).get(
|
||||
missing_trigger
|
||||
)
|
||||
) is None:
|
||||
|
@@ -35,10 +35,10 @@ fnv-hash-fast==1.5.0
|
||||
go2rtc-client==0.2.1
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==4.0.1
|
||||
hass-nabucasa==0.110.1
|
||||
hass-nabucasa==0.111.0
|
||||
hassil==2.2.3
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20250731.0
|
||||
home-assistant-frontend==20250805.0
|
||||
home-assistant-intents==2025.7.30
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
@@ -47,7 +47,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.5.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==0.110.1",
|
||||
"hass-nabucasa==0.111.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
|
2
requirements.txt
generated
2
requirements.txt
generated
@@ -22,7 +22,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.2
|
||||
cronsim==2.6
|
||||
fnv-hash-fast==1.5.0
|
||||
hass-nabucasa==0.110.1
|
||||
hass-nabucasa==0.111.0
|
||||
httpx==0.28.1
|
||||
home-assistant-bluetooth==1.13.1
|
||||
ifaddr==0.2.0
|
||||
|
16
requirements_all.txt
generated
16
requirements_all.txt
generated
@@ -204,7 +204,7 @@ aioaseko==1.0.0
|
||||
aioasuswrt==1.4.0
|
||||
|
||||
# homeassistant.components.husqvarna_automower
|
||||
aioautomower==2.1.1
|
||||
aioautomower==2.1.2
|
||||
|
||||
# homeassistant.components.azure_devops
|
||||
aioazuredevops==2.2.1
|
||||
@@ -453,7 +453,7 @@ airgradient==0.9.2
|
||||
airly==1.1.0
|
||||
|
||||
# homeassistant.components.airos
|
||||
airos==0.2.1
|
||||
airos==0.2.4
|
||||
|
||||
# homeassistant.components.airthings_ble
|
||||
airthings-ble==0.9.2
|
||||
@@ -777,7 +777,7 @@ decora-wifi==1.4
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==13.5.0
|
||||
deebot-client==13.6.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -1133,7 +1133,7 @@ habiticalib==0.4.1
|
||||
habluetooth==4.0.1
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==0.110.1
|
||||
hass-nabucasa==0.111.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.1
|
||||
@@ -1174,7 +1174,7 @@ hole==0.9.0
|
||||
holidays==0.77
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20250731.0
|
||||
home-assistant-frontend==20250805.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.7.30
|
||||
@@ -1216,7 +1216,7 @@ ibmiotf==0.3.4
|
||||
ical==11.0.0
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.1.0
|
||||
icalendar==6.3.1
|
||||
|
||||
# homeassistant.components.ping
|
||||
icmplib==3.0
|
||||
@@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.7.23.50952
|
||||
knx-frontend==2025.8.4.154919
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -3133,7 +3133,7 @@ wolf-comm==0.0.23
|
||||
wsdot==0.0.1
|
||||
|
||||
# homeassistant.components.wyoming
|
||||
wyoming==1.7.1
|
||||
wyoming==1.7.2
|
||||
|
||||
# homeassistant.components.xbox
|
||||
xbox-webapi==2.1.0
|
||||
|
@@ -13,7 +13,7 @@ freezegun==1.5.2
|
||||
go2rtc-client==0.2.1
|
||||
license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy-dev==1.18.0a3
|
||||
mypy-dev==1.18.0a4
|
||||
pre-commit==4.2.0
|
||||
pydantic==2.11.7
|
||||
pylint==3.3.7
|
||||
|
16
requirements_test_all.txt
generated
16
requirements_test_all.txt
generated
@@ -192,7 +192,7 @@ aioaseko==1.0.0
|
||||
aioasuswrt==1.4.0
|
||||
|
||||
# homeassistant.components.husqvarna_automower
|
||||
aioautomower==2.1.1
|
||||
aioautomower==2.1.2
|
||||
|
||||
# homeassistant.components.azure_devops
|
||||
aioazuredevops==2.2.1
|
||||
@@ -435,7 +435,7 @@ airgradient==0.9.2
|
||||
airly==1.1.0
|
||||
|
||||
# homeassistant.components.airos
|
||||
airos==0.2.1
|
||||
airos==0.2.4
|
||||
|
||||
# homeassistant.components.airthings_ble
|
||||
airthings-ble==0.9.2
|
||||
@@ -677,7 +677,7 @@ debugpy==1.8.14
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==13.5.0
|
||||
deebot-client==13.6.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -994,7 +994,7 @@ habiticalib==0.4.1
|
||||
habluetooth==4.0.1
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==0.110.1
|
||||
hass-nabucasa==0.111.0
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
@@ -1023,7 +1023,7 @@ hole==0.9.0
|
||||
holidays==0.77
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20250731.0
|
||||
home-assistant-frontend==20250805.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.7.30
|
||||
@@ -1056,7 +1056,7 @@ ibeacon-ble==1.2.0
|
||||
ical==11.0.0
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.1.0
|
||||
icalendar==6.3.1
|
||||
|
||||
# homeassistant.components.ping
|
||||
icmplib==3.0
|
||||
@@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.7.23.50952
|
||||
knx-frontend==2025.8.4.154919
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -2586,7 +2586,7 @@ wolf-comm==0.0.23
|
||||
wsdot==0.0.1
|
||||
|
||||
# homeassistant.components.wyoming
|
||||
wyoming==1.7.1
|
||||
wyoming==1.7.2
|
||||
|
||||
# homeassistant.components.xbox
|
||||
xbox-webapi==2.1.0
|
||||
|
@@ -136,7 +136,7 @@ TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
|
||||
vol.Optional("trigger"): icon_value_validator,
|
||||
}
|
||||
),
|
||||
slug_validator=translation_key_validator,
|
||||
slug_validator=cv.underscore_slug,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -450,7 +450,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
|
||||
slug_validator=translation_key_validator,
|
||||
),
|
||||
},
|
||||
slug_validator=translation_key_validator,
|
||||
slug_validator=cv.underscore_slug,
|
||||
),
|
||||
vol.Optional("conversation"): {
|
||||
vol.Required("agent"): {
|
||||
|
@@ -50,7 +50,7 @@ TRIGGER_SCHEMA = vol.Any(
|
||||
TRIGGERS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Remove(vol.All(str, trigger.starts_with_dot)): object,
|
||||
cv.slug: TRIGGER_SCHEMA,
|
||||
cv.underscore_slug: TRIGGER_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
@@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away(
|
||||
assert hass_storage[auth_store.STORAGE_KEY] != {}
|
||||
|
||||
|
||||
async def test_duplicate_uuid(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test we don't override user if we have a duplicate user ID."""
|
||||
hass_storage[auth_store.STORAGE_KEY] = MOCK_STORAGE_DATA
|
||||
store = auth_store.AuthStore(hass)
|
||||
await store.async_load()
|
||||
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock:
|
||||
hex_mock.side_effect = ["user-id", "new-id"]
|
||||
user = await store.async_create_user("Test User")
|
||||
assert len(hex_mock.mock_calls) == 2
|
||||
assert user.id == "new-id"
|
||||
|
||||
|
||||
async def test_add_remove_user_affects_tokens(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
|
@@ -439,64 +439,6 @@
|
||||
'state': '5500',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'ap_ptp',
|
||||
'sta_ptp',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Wireless mode',
|
||||
'platform': 'airos',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'wireless_mode',
|
||||
'unique_id': '01:23:45:67:89:AB_wireless_mode',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'NanoStation 5AC ap name Wireless mode',
|
||||
'options': list([
|
||||
'ap_ptp',
|
||||
'sta_ptp',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'ap_ptp',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
@@ -4,9 +4,9 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from airos.exceptions import (
|
||||
ConnectionAuthenticationError,
|
||||
DeviceConnectionError,
|
||||
KeyDataMissingError,
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSKeyDataMissingError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
@@ -78,9 +78,9 @@ async def test_form_duplicate_entry(
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(ConnectionAuthenticationError, "invalid_auth"),
|
||||
(DeviceConnectionError, "cannot_connect"),
|
||||
(KeyDataMissingError, "key_data_missing"),
|
||||
(AirOSConnectionAuthenticationError, "invalid_auth"),
|
||||
(AirOSDeviceConnectionError, "cannot_connect"),
|
||||
(AirOSKeyDataMissingError, "key_data_missing"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
|
@@ -4,9 +4,9 @@ from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from airos.exceptions import (
|
||||
ConnectionAuthenticationError,
|
||||
DataMissingError,
|
||||
DeviceConnectionError,
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
@@ -39,10 +39,10 @@ async def test_all_entities(
|
||||
@pytest.mark.parametrize(
|
||||
("exception"),
|
||||
[
|
||||
ConnectionAuthenticationError,
|
||||
AirOSConnectionAuthenticationError,
|
||||
TimeoutError,
|
||||
DeviceConnectionError,
|
||||
DataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSDataMissingError,
|
||||
],
|
||||
)
|
||||
async def test_sensor_update_exception_handling(
|
||||
|
94
tests/components/downloader/conftest.py
Normal file
94
tests/components/downloader/conftest.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Provide common fixtures for downloader tests."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from requests_mock import Mocker
|
||||
|
||||
from homeassistant.components.downloader.const import (
|
||||
CONF_DOWNLOAD_DIR,
|
||||
DOMAIN,
|
||||
DOWNLOAD_COMPLETED_EVENT,
|
||||
DOWNLOAD_FAILED_EVENT,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the downloader integration for testing."""
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(
|
||||
hass: HomeAssistant,
|
||||
download_dir: Path,
|
||||
) -> MockConfigEntry:
|
||||
"""Return a mocked config entry."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_DOWNLOAD_DIR: str(download_dir)},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
return config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_dir(tmp_path: Path) -> Path:
|
||||
"""Return a download directory."""
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_download_request(
|
||||
requests_mock: Mocker,
|
||||
download_url: str,
|
||||
) -> None:
|
||||
"""Mock the download request."""
|
||||
requests_mock.get(download_url, text="{'one': 1}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_url() -> str:
|
||||
"""Return a mock download URL."""
|
||||
return "http://example.com/file.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_completed(hass: HomeAssistant) -> asyncio.Event:
|
||||
"""Return an asyncio event to wait for download completion."""
|
||||
download_event = asyncio.Event()
|
||||
|
||||
@callback
|
||||
def download_set(event: Event[dict[str, str]]) -> None:
|
||||
"""Set the event when download is completed."""
|
||||
download_event.set()
|
||||
|
||||
hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", download_set)
|
||||
|
||||
return download_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_failed(hass: HomeAssistant) -> asyncio.Event:
|
||||
"""Return an asyncio event to wait for download failure."""
|
||||
download_event = asyncio.Event()
|
||||
|
||||
@callback
|
||||
def download_set(event: Event[dict[str, str]]) -> None:
|
||||
"""Set the event when download has failed."""
|
||||
download_event.set()
|
||||
|
||||
hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", download_set)
|
||||
|
||||
return download_event
|
@@ -1,6 +1,8 @@
|
||||
"""Tests for the downloader component init."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.downloader.const import (
|
||||
CONF_DOWNLOAD_DIR,
|
||||
@@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_initialization(hass: HomeAssistant) -> None:
|
||||
"""Test the initialization of the downloader component."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_DOWNLOAD_DIR: "/test_dir",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch("os.path.isdir", return_value=True):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
@pytest.fixture
|
||||
def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
|
||||
"""Return a download directory."""
|
||||
if hasattr(request, "param"):
|
||||
return tmp_path / request.param
|
||||
return tmp_path
|
||||
|
||||
|
||||
async def test_config_entry_setup(
|
||||
hass: HomeAssistant, setup_integration: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test config entry setup."""
|
||||
config_entry = setup_integration
|
||||
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_config_entry_setup_relative_directory(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test config entry setup with a relative download directory."""
|
||||
relative_directory = "downloads"
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
data={**mock_config_entry.data, CONF_DOWNLOAD_DIR: relative_directory},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
# The config entry will fail to set up since the directory does not exist.
|
||||
# This is not relevant for this test.
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert mock_config_entry.data[CONF_DOWNLOAD_DIR] == hass.config.path(
|
||||
relative_directory
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"download_dir",
|
||||
[
|
||||
"not_existing_path",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_config_entry_setup_not_existing_directory(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config entry setup without existing download directory."""
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE)
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
54
tests/components/downloader/test_services.py
Normal file
54
tests/components/downloader/test_services.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Test downloader services."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.downloader.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("subdir", "expected_result"),
|
||||
[
|
||||
("test", does_not_raise()),
|
||||
("test/path", does_not_raise()),
|
||||
("~test/path", pytest.raises(ServiceValidationError)),
|
||||
("~/../test/path", pytest.raises(ServiceValidationError)),
|
||||
("../test/path", pytest.raises(ServiceValidationError)),
|
||||
(".../test/path", pytest.raises(ServiceValidationError)),
|
||||
("/test/path", pytest.raises(ServiceValidationError)),
|
||||
],
|
||||
)
|
||||
async def test_download_invalid_subdir(
|
||||
hass: HomeAssistant,
|
||||
download_completed: asyncio.Event,
|
||||
download_failed: asyncio.Event,
|
||||
download_url: str,
|
||||
subdir: str,
|
||||
expected_result: AbstractContextManager,
|
||||
) -> None:
|
||||
"""Test service invalid subdirectory."""
|
||||
|
||||
async def call_service() -> None:
|
||||
"""Call the download service."""
|
||||
completed = hass.async_create_task(download_completed.wait())
|
||||
failed = hass.async_create_task(download_failed.wait())
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"download_file",
|
||||
{
|
||||
"url": download_url,
|
||||
"subdir": subdir,
|
||||
"filename": "file.txt",
|
||||
"overwrite": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await asyncio.wait((completed, failed), return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
with expected_result:
|
||||
await call_service()
|
@@ -400,10 +400,10 @@ async def test_options_flow_preview(
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["state"] == exp_count
|
||||
|
||||
hass.states.async_set(monitored_entity, "on")
|
||||
hass.states.async_set(monitored_entity, "on")
|
||||
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["state"] == "3"
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["state"] == "3"
|
||||
|
||||
|
||||
async def test_options_flow_preview_errors(
|
||||
|
@@ -47,6 +47,54 @@
|
||||
'state': 'unavailable',
|
||||
})
|
||||
# ---
|
||||
# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-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': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Reset cutting blade usage time',
|
||||
'platform': 'husqvarna_automower',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'reset_cutting_blade_usage_time',
|
||||
'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_reset_cutting_blade_usage_time',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Mower 1 Reset cutting blade usage time',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_button_snapshot[button.test_mower_1_sync_clock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
@@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC))
|
||||
async def test_button_states_and_commands(
|
||||
async def test_button_error_confirm(
|
||||
hass: HomeAssistant,
|
||||
mock_automower_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -58,42 +58,43 @@ async def test_button_states_and_commands(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
await hass.services.async_call(
|
||||
domain="button",
|
||||
service=SERVICE_PRESS,
|
||||
target={ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "2023-06-05T00:16:00+00:00"
|
||||
mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error")
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Failed to send command: Test error",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
domain="button",
|
||||
service=SERVICE_PRESS,
|
||||
target={ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "name", "expected_command"),
|
||||
[
|
||||
(
|
||||
"button.test_mower_1_confirm_error",
|
||||
"Test Mower 1 Confirm error",
|
||||
"error_confirm",
|
||||
),
|
||||
(
|
||||
"button.test_mower_1_sync_clock",
|
||||
"Test Mower 1 Sync clock",
|
||||
"set_datetime",
|
||||
),
|
||||
(
|
||||
"button.test_mower_1_reset_cutting_blade_usage_time",
|
||||
"Test Mower 1 Reset cutting blade usage time",
|
||||
"reset_cutting_blade_usage_time",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC))
|
||||
async def test_sync_clock(
|
||||
async def test_button_commands(
|
||||
hass: HomeAssistant,
|
||||
mock_automower_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
values: dict[str, MowerAttributes],
|
||||
entity_id: str,
|
||||
name: str,
|
||||
expected_command: str,
|
||||
) -> None:
|
||||
"""Test sync clock button command."""
|
||||
entity_id = "button.test_mower_1_sync_clock"
|
||||
"""Test Automower button commands."""
|
||||
values[TEST_MOWER_ID].mower.is_error_confirmable = True
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "Test Mower 1 Sync clock"
|
||||
assert state.name == name
|
||||
|
||||
mock_automower_client.get_status.return_value = values
|
||||
|
||||
@@ -103,11 +104,15 @@ async def test_sync_clock(
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID)
|
||||
|
||||
command_mock = getattr(mock_automower_client.commands, expected_command)
|
||||
command_mock.assert_called_once_with(TEST_MOWER_ID)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "2024-02-29T11:00:00+00:00"
|
||||
mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error")
|
||||
command_mock.reset_mock()
|
||||
command_mock.side_effect = ApiError("Test error")
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Failed to send command: Test error",
|
||||
|
@@ -14,7 +14,7 @@ from aioautomower.exceptions import (
|
||||
HusqvarnaTimeoutError,
|
||||
HusqvarnaWSServerHandshakeError,
|
||||
)
|
||||
from aioautomower.model import Calendar, MowerAttributes, WorkArea
|
||||
from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -484,3 +484,212 @@ async def test_add_and_remove_work_area(
|
||||
- ADDITIONAL_NUMBER_ENTITIES
|
||||
- ADDITIONAL_SENSOR_ENTITIES
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"),
|
||||
[
|
||||
(True, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||
(False, MowerStates.PAUSED, False, MowerStates.OFF), # False
|
||||
(False, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||
(False, MowerStates.OFF, False, MowerStates.PAUSED), # False
|
||||
(True, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||
(False, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||
],
|
||||
)
|
||||
async def test_dynamic_polling(
|
||||
hass: HomeAssistant,
|
||||
mock_automower_client,
|
||||
mock_config_entry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
values: dict[str, MowerAttributes],
|
||||
mower1_connected: bool,
|
||||
mower1_state: MowerStates,
|
||||
mower2_connected: bool,
|
||||
mower2_state: MowerStates,
|
||||
) -> None:
|
||||
"""Test that the ws_ready_callback triggers an attempt to start the Watchdog task.
|
||||
|
||||
and that the pong callback stops polling when all mowers are inactive.
|
||||
"""
|
||||
websocket_values = deepcopy(values)
|
||||
poll_values = deepcopy(values)
|
||||
callback_holder: dict[str, Callable] = {}
|
||||
|
||||
@callback
|
||||
def fake_register_websocket_response(
|
||||
cb: Callable[[dict[str, MowerAttributes]], None],
|
||||
) -> None:
|
||||
callback_holder["data_cb"] = cb
|
||||
|
||||
mock_automower_client.register_data_callback.side_effect = (
|
||||
fake_register_websocket_response
|
||||
)
|
||||
|
||||
@callback
|
||||
def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None:
|
||||
callback_holder["ws_ready_cb"] = cb
|
||||
|
||||
mock_automower_client.register_ws_ready_callback.side_effect = (
|
||||
fake_register_ws_ready_callback
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered"
|
||||
callback_holder["ws_ready_cb"]()
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 1
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 2
|
||||
|
||||
# websocket is still active, but mowers are inactive -> no polling required
|
||||
poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected
|
||||
poll_values[TEST_MOWER_ID].mower.state = mower1_state
|
||||
poll_values["1234"].metadata.connected = mower2_connected
|
||||
poll_values["1234"].mower.state = mower2_state
|
||||
|
||||
mock_automower_client.get_status.return_value = poll_values
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 3
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 4
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 4
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 4
|
||||
|
||||
# websocket is still active, and mowers are active -> polling required
|
||||
mock_automower_client.get_status.reset_mock()
|
||||
assert mock_automower_client.get_status.call_count == 0
|
||||
poll_values[TEST_MOWER_ID].metadata.connected = True
|
||||
poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED
|
||||
poll_values["1234"].metadata.connected = False
|
||||
poll_values["1234"].mower.state = MowerStates.OFF
|
||||
websocket_values = deepcopy(poll_values)
|
||||
callback_holder["data_cb"](websocket_values)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 1
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"),
|
||||
[
|
||||
(True, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||
(False, MowerStates.PAUSED, False, MowerStates.OFF), # False
|
||||
(False, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||
(False, MowerStates.OFF, False, MowerStates.PAUSED), # False
|
||||
(True, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||
(False, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||
],
|
||||
)
|
||||
async def test_websocket_watchdog(
|
||||
hass: HomeAssistant,
|
||||
mock_automower_client,
|
||||
mock_config_entry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
values: dict[str, MowerAttributes],
|
||||
mower1_connected: bool,
|
||||
mower1_state: MowerStates,
|
||||
mower2_connected: bool,
|
||||
mower2_state: MowerStates,
|
||||
) -> None:
|
||||
"""Test that the ws_ready_callback triggers an attempt to start the Watchdog task.
|
||||
|
||||
and that the pong callback stops polling when all mowers are inactive.
|
||||
"""
|
||||
poll_values = deepcopy(values)
|
||||
callback_holder: dict[str, Callable] = {}
|
||||
|
||||
@callback
|
||||
def fake_register_websocket_response(
|
||||
cb: Callable[[dict[str, MowerAttributes]], None],
|
||||
) -> None:
|
||||
callback_holder["data_cb"] = cb
|
||||
|
||||
mock_automower_client.register_data_callback.side_effect = (
|
||||
fake_register_websocket_response
|
||||
)
|
||||
|
||||
@callback
|
||||
def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None:
|
||||
callback_holder["ws_ready_cb"] = cb
|
||||
|
||||
mock_automower_client.register_ws_ready_callback.side_effect = (
|
||||
fake_register_ws_ready_callback
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered"
|
||||
callback_holder["ws_ready_cb"]()
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 1
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 2
|
||||
|
||||
# websocket is still active, but mowers are inactive -> no polling required
|
||||
poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected
|
||||
poll_values[TEST_MOWER_ID].mower.state = mower1_state
|
||||
poll_values["1234"].metadata.connected = mower2_connected
|
||||
poll_values["1234"].mower.state = mower2_state
|
||||
|
||||
mock_automower_client.get_status.return_value = poll_values
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 3
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 4
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 4
|
||||
|
||||
# Simulate Pong loss and reset mock -> polling required
|
||||
mock_automower_client.send_empty_message.return_value = False
|
||||
mock_automower_client.get_status.reset_mock()
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 0
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 1
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_automower_client.get_status.call_count == 2
|
||||
|
@@ -203,7 +203,7 @@
|
||||
"1/6/65528": [],
|
||||
"1/6/65529": [0, 1, 2],
|
||||
"1/6/65531": [0, 65532, 65533, 65528, 65529, 65531],
|
||||
"1/8/0": 254,
|
||||
"1/8/0": 200,
|
||||
"1/8/15": 0,
|
||||
"1/8/17": 0,
|
||||
"1/8/65532": 0,
|
||||
|
@@ -2189,7 +2189,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '127.0',
|
||||
'state': '100.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry]
|
||||
|
@@ -172,7 +172,7 @@ async def test_pump_level(
|
||||
# CurrentLevel on LevelControl cluster
|
||||
state = hass.states.get("number.mock_pump_setpoint")
|
||||
assert state
|
||||
assert state.state == "127.0"
|
||||
assert state.state == "100.0"
|
||||
|
||||
set_node_attribute(matter_node, 1, 8, 0, 100)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -130,6 +130,28 @@ def mock_smile_config_flow() -> Generator[MagicMock]:
|
||||
yield api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture for platforms."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
platforms,
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Set up one or all platforms."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
yield mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_smile_adam() -> Generator[MagicMock]:
|
||||
"""Create a Mock Adam environment for testing exceptions."""
|
||||
|
50
tests/components/plugwise/snapshots/test_button.ambr
Normal file
50
tests/components/plugwise/snapshots/test_button.ambr
Normal file
@@ -0,0 +1,50 @@
|
||||
# serializer version: 1
|
||||
# name: test_adam_button_snapshot[platforms0][button.adam_reboot-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': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.adam_reboot',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.RESTART: 'restart'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Reboot',
|
||||
'platform': 'plugwise',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'reboot',
|
||||
'unique_id': 'fe799307f1624099878210aa0b9f1475-reboot',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_adam_button_snapshot[platforms0][button.adam_reboot-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'restart',
|
||||
'friendly_name': 'Adam Reboot',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.adam_reboot',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
@@ -2,32 +2,34 @@
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeassistant.components.button import (
|
||||
DOMAIN as BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
ButtonDeviceClass,
|
||||
)
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
async def test_adam_reboot_button(
|
||||
@pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)])
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_adam_button_snapshot(
|
||||
hass: HomeAssistant,
|
||||
mock_smile_adam: MagicMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
setup_platform: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Adam button snapshot."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
|
||||
|
||||
|
||||
async def test_adam_press_reboot_button(
|
||||
hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test creation of button entities."""
|
||||
state = hass.states.get("button.adam_reboot")
|
||||
assert state
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART
|
||||
|
||||
registry = er.async_get(hass)
|
||||
entry = registry.async_get("button.adam_reboot")
|
||||
assert entry
|
||||
assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot"
|
||||
|
||||
"""Test pressing of button entity."""
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
|
@@ -90,8 +90,8 @@
|
||||
'null': 5,
|
||||
}),
|
||||
'GetAiCfg': dict({
|
||||
'0': 4,
|
||||
'null': 4,
|
||||
'0': 2,
|
||||
'null': 2,
|
||||
}),
|
||||
'GetAudioAlarm': dict({
|
||||
'0': 1,
|
||||
@@ -177,10 +177,6 @@
|
||||
'0': 2,
|
||||
'null': 2,
|
||||
}),
|
||||
'GetPtzTraceSection': dict({
|
||||
'0': 2,
|
||||
'null': 2,
|
||||
}),
|
||||
'GetPush': dict({
|
||||
'0': 1,
|
||||
'null': 2,
|
||||
@@ -196,8 +192,8 @@
|
||||
'null': 1,
|
||||
}),
|
||||
'GetWhiteLed': dict({
|
||||
'0': 3,
|
||||
'null': 3,
|
||||
'0': 2,
|
||||
'null': 2,
|
||||
}),
|
||||
'GetZoomFocus': dict({
|
||||
'0': 2,
|
||||
|
@@ -15,7 +15,7 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers.entity_component import async_update_entity
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -589,6 +589,40 @@ async def test_battery_level_template(
|
||||
_verify(hass, STATE_UNKNOWN, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("count", "state_template", "extra_config", "attribute_template"),
|
||||
[(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("style", "attribute"),
|
||||
[
|
||||
(ConfigurationStyle.LEGACY, "battery_level_template"),
|
||||
(ConfigurationStyle.MODERN, "battery_level"),
|
||||
(ConfigurationStyle.TRIGGER, "battery_level"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("setup_single_attribute_state_vacuum")
|
||||
async def test_battery_level_template_repair(
|
||||
hass: HomeAssistant,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test battery_level template raises issue."""
|
||||
# Ensure trigger entity templates are rendered
|
||||
hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(issue_registry.issues) == 1
|
||||
issue = issue_registry.async_get_issue(
|
||||
"template", f"deprecated_battery_level_{TEST_ENTITY_ID}"
|
||||
)
|
||||
assert issue.domain == "template"
|
||||
assert issue.severity == ir.IssueSeverity.WARNING
|
||||
assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID
|
||||
assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID
|
||||
assert "Detected that integration 'template' is setting the" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("count", "state_template", "extra_config"),
|
||||
[
|
||||
|
@@ -41,6 +41,11 @@ DEVICE_MOCKS = {
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"cs_qhxmvae667uap4zh": [
|
||||
# https://github.com/home-assistant/core/issues/141278
|
||||
Platform.FAN,
|
||||
Platform.HUMIDIFIER,
|
||||
],
|
||||
"cs_vmxuxszzjwp5smli": [
|
||||
# https://github.com/home-assistant/core/issues/119865
|
||||
Platform.FAN,
|
||||
@@ -214,6 +219,16 @@ DEVICE_MOCKS = {
|
||||
# https://github.com/home-assistant/core/issues/143499
|
||||
Platform.SENSOR,
|
||||
],
|
||||
"fs_g0ewlb1vmwqljzji": [
|
||||
# https://github.com/home-assistant/core/issues/141231
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
],
|
||||
"fs_ibytpo6fpnugft1c": [
|
||||
# https://github.com/home-assistant/core/issues/135541
|
||||
Platform.FAN,
|
||||
],
|
||||
"gyd_lgekqfxdabipm3tn": [
|
||||
# https://github.com/home-assistant/core/issues/133173
|
||||
Platform.LIGHT,
|
||||
@@ -227,6 +242,11 @@ DEVICE_MOCKS = {
|
||||
# https://github.com/home-assistant/core/issues/148347
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"kj_CAjWAxBUZt7QZHfz": [
|
||||
# https://github.com/home-assistant/core/issues/146023
|
||||
Platform.FAN,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"kj_yrzylxax1qspdgpp": [
|
||||
# https://github.com/orgs/home-assistant/discussions/61
|
||||
Platform.FAN,
|
||||
@@ -294,6 +314,15 @@ DEVICE_MOCKS = {
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
],
|
||||
"sd_lr33znaodtyarrrz": [
|
||||
# https://github.com/home-assistant/core/issues/141278
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
],
|
||||
"sfkzq_o6dagifntoafakst": [
|
||||
# https://github.com/home-assistant/core/issues/148116
|
||||
Platform.SWITCH,
|
||||
@@ -306,16 +335,27 @@ DEVICE_MOCKS = {
|
||||
],
|
||||
"sp_drezasavompxpcgm": [
|
||||
# https://github.com/home-assistant/core/issues/149704
|
||||
Platform.CAMERA,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"sp_rjKXWRohlvOTyLBu": [
|
||||
# https://github.com/home-assistant/core/issues/149704
|
||||
Platform.CAMERA,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"sp_sdd5f5f2dl5wydjf": [
|
||||
# https://github.com/home-assistant/core/issues/144087
|
||||
Platform.CAMERA,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SELECT,
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"tdq_cq1p0nt0a4rixnex": [
|
||||
# https://github.com/home-assistant/core/issues/146845
|
||||
Platform.SELECT,
|
||||
@@ -335,6 +375,10 @@ DEVICE_MOCKS = {
|
||||
Platform.CLIMATE,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
"wg2_nwxr8qcu4seltoro": [
|
||||
# https://github.com/orgs/home-assistant/discussions/430
|
||||
Platform.BINARY_SENSOR,
|
||||
],
|
||||
"wk_fi6dne5tu4t1nm6j": [
|
||||
# https://github.com/orgs/home-assistant/discussions/243
|
||||
Platform.CLIMATE,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1729466466688hgsTp2",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaus.com",
|
||||
"terminal_id": "1732306182276g6jQLp",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "mock_terminal_id",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
32
tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json
Normal file
32
tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
"id": "28403630e8db84b7a963",
|
||||
"name": "DryFix",
|
||||
"category": "cs",
|
||||
"product_id": "qhxmvae667uap4zh",
|
||||
"product_name": "",
|
||||
"online": false,
|
||||
"sub": false,
|
||||
"time_zone": "+01:00",
|
||||
"active_time": "2024-04-03T13:10:02+00:00",
|
||||
"create_time": "2024-04-03T13:10:02+00:00",
|
||||
"update_time": "2024-04-03T13:10:02+00:00",
|
||||
"function": {},
|
||||
"status_range": {
|
||||
"fault": {
|
||||
"type": "Bitmap",
|
||||
"value": {
|
||||
"label": ["E1", "E2"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"fault": 0
|
||||
},
|
||||
"set_up": true,
|
||||
"support_local": true
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "mock_terminal_id",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1750837476328i3TNXQ",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1747045731408d0tb5M",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1751729689584Vh0VoL",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaus.com",
|
||||
"terminal_id": "1742695000703Ozq34h",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1733006572651YokbqV",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": null,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
134
tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json
Normal file
134
tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
"id": "XXX",
|
||||
"name": "Ceiling Fan With Light",
|
||||
"category": "fs",
|
||||
"product_id": "g0ewlb1vmwqljzji",
|
||||
"product_name": "Ceiling Fan With Light",
|
||||
"online": true,
|
||||
"sub": false,
|
||||
"time_zone": "+01:00",
|
||||
"active_time": "2025-03-22T22:57:04+00:00",
|
||||
"create_time": "2025-03-22T22:57:04+00:00",
|
||||
"update_time": "2025-03-22T22:57:04+00:00",
|
||||
"function": {
|
||||
"switch": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"mode": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["normal", "sleep", "nature"]
|
||||
}
|
||||
},
|
||||
"fan_speed": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["1", "2", "3", "4", "5", "6"]
|
||||
}
|
||||
},
|
||||
"fan_direction": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["forward", "reverse"]
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"bright_value": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"temp_value": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"countdown_set": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["cancel", "1h", "2h", "4h", "8h"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status_range": {
|
||||
"switch": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"mode": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["normal", "sleep", "nature"]
|
||||
}
|
||||
},
|
||||
"fan_speed": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["1", "2", "3", "4", "5", "6"]
|
||||
}
|
||||
},
|
||||
"fan_direction": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["forward", "reverse"]
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"bright_value": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"temp_value": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"countdown_set": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["cancel", "1h", "2h", "4h", "8h"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"switch": true,
|
||||
"mode": "normal",
|
||||
"fan_speed": 1,
|
||||
"fan_direction": "reverse",
|
||||
"light": true,
|
||||
"bright_value": 100,
|
||||
"temp_value": 0,
|
||||
"countdown_set": "off"
|
||||
},
|
||||
"set_up": true,
|
||||
"support_local": true
|
||||
}
|
23
tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json
Normal file
23
tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
"id": "10706550a4e57c88b93a",
|
||||
"name": "Ventilador Cama",
|
||||
"category": "fs",
|
||||
"product_id": "ibytpo6fpnugft1c",
|
||||
"product_name": "Tower bladeless fan ",
|
||||
"online": true,
|
||||
"sub": false,
|
||||
"time_zone": "+01:00",
|
||||
"active_time": "2025-01-10T18:47:46+00:00",
|
||||
"create_time": "2025-01-10T18:47:46+00:00",
|
||||
"update_time": "2025-01-10T18:47:46+00:00",
|
||||
"function": {},
|
||||
"status_range": {},
|
||||
"status": {},
|
||||
"set_up": true,
|
||||
"support_local": true
|
||||
}
|
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaus.com",
|
||||
"terminal_id": "1732306182276g6jQLp",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
||||
"id": "eb3e988f33c233290cfs3l",
|
||||
"name": "Colorful PIR Night Light",
|
||||
"category": "gyd",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaus.com",
|
||||
"terminal_id": "1750526976566fMhqJs",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": true,
|
||||
|
86
tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json
Normal file
86
tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
"id": "152027113c6105cce49c",
|
||||
"name": "HL400",
|
||||
"category": "kj",
|
||||
"product_id": "CAjWAxBUZt7QZHfz",
|
||||
"product_name": "air purifier",
|
||||
"online": true,
|
||||
"sub": false,
|
||||
"time_zone": "+01:00",
|
||||
"active_time": "2025-05-13T11:02:55+00:00",
|
||||
"create_time": "2025-05-13T11:02:55+00:00",
|
||||
"update_time": "2025-05-13T11:02:55+00:00",
|
||||
"function": {
|
||||
"switch": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"speed": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["1", "2", "3"]
|
||||
}
|
||||
},
|
||||
"anion": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"lock": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"uv": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
}
|
||||
},
|
||||
"status_range": {
|
||||
"uv": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"lock": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"anion": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"speed": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["1", "2", "3"]
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"pm25": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"unit": "",
|
||||
"min": 0,
|
||||
"max": 500,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"switch": true,
|
||||
"lock": false,
|
||||
"anion": true,
|
||||
"speed": 3,
|
||||
"uv": true,
|
||||
"pm25": 45
|
||||
},
|
||||
"set_up": true,
|
||||
"support_local": true
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "CENSORED",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "mock_terminal_id",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "mock_terminal_id",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1737479380414pasuj4",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1751921699759JsVujI",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"terminal_id": "1708196692712PHOeqy",
|
||||
"terminal_id": "REDACTED",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user