Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into target_trigger

This commit is contained in:
abmantis
2025-08-05 17:06:47 +01:00
142 changed files with 6277 additions and 362 deletions

View File

@@ -190,7 +190,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -256,7 +256,7 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -330,14 +330,14 @@ jobs:
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -502,7 +502,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI - name: Detect duplicates using AI
id: ai_detection id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' 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: with:
model: openai/gpt-4o model: openai/gpt-4o
system-prompt: | system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI - name: Detect language using AI
id: ai_language_detection id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true' if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@v1.2.4 uses: actions/ai-inference@v1.2.7
with: with:
model: openai/gpt-4o-mini model: openai/gpt-4o-mini
system-prompt: | system-prompt: |

View File

@@ -120,6 +120,9 @@ class AuthStore:
new_user = models.User(**kwargs) new_user = models.User(**kwargs)
while new_user.id in self._users:
new_user = models.User(**kwargs)
self._users[new_user.id] = new_user self._users[new_user.id] = new_user
if credentials is None: if credentials is None:

View File

@@ -6,11 +6,11 @@ import logging
from typing import Any from typing import Any
from airos.exceptions import ( from airos.exceptions import (
ConnectionAuthenticationError, AirOSConnectionAuthenticationError,
ConnectionSetupError, AirOSConnectionSetupError,
DataMissingError, AirOSDataMissingError,
DeviceConnectionError, AirOSDeviceConnectionError,
KeyDataMissingError, AirOSKeyDataMissingError,
) )
import voluptuous as vol import voluptuous as vol
@@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
airos_data = await airos_device.status() airos_data = await airos_device.status()
except ( except (
ConnectionSetupError, AirOSConnectionSetupError,
DeviceConnectionError, AirOSDeviceConnectionError,
): ):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except (ConnectionAuthenticationError, DataMissingError): except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except KeyDataMissingError: except AirOSKeyDataMissingError:
errors["base"] = "key_data_missing" errors["base"] = "key_data_missing"
except Exception: except Exception:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")

View File

@@ -6,10 +6,10 @@ import logging
from airos.airos8 import AirOS, AirOSData from airos.airos8 import AirOS, AirOSData
from airos.exceptions import ( from airos.exceptions import (
ConnectionAuthenticationError, AirOSConnectionAuthenticationError,
ConnectionSetupError, AirOSConnectionSetupError,
DataMissingError, AirOSDataMissingError,
DeviceConnectionError, AirOSDeviceConnectionError,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -47,18 +47,22 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
try: try:
await self.airos_device.login() await self.airos_device.login()
return await self.airos_device.status() return await self.airos_device.status()
except (ConnectionAuthenticationError,) as err: except (AirOSConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device") _LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryError( raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth" translation_domain=DOMAIN, translation_key="invalid_auth"
) from err ) from err
except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err: except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
_LOGGER.error("Error connecting to airOS device: %s", err) _LOGGER.error("Error connecting to airOS device: %s", err)
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="cannot_connect", translation_key="cannot_connect",
) from err ) from err
except (DataMissingError,) as err: except (AirOSDataMissingError,) as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err) _LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos", "documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["airos==0.2.1"] "requirements": ["airos==0.2.4"]
} }

View File

@@ -69,13 +69,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
translation_key="wireless_essid", translation_key="wireless_essid",
value_fn=lambda data: data.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( AirOSSensorEntityDescription(
key="wireless_antenna_gain", key="wireless_antenna_gain",
translation_key="wireless_antenna_gain", translation_key="wireless_antenna_gain",

View File

@@ -43,13 +43,6 @@
"wireless_essid": { "wireless_essid": {
"name": "Wireless SSID" "name": "Wireless SSID"
}, },
"wireless_mode": {
"name": "Wireless mode",
"state": {
"ap_ptp": "Access point",
"sta_ptp": "Station"
}
},
"wireless_antenna_gain": { "wireless_antenna_gain": {
"name": "Antenna gain" "name": "Antenna gain"
}, },

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/caldav", "documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"], "loggers": ["caldav", "vobject"],
"requirements": ["caldav==1.6.0", "icalendar==6.1.0"] "requirements": ["caldav==1.6.0", "icalendar==6.3.1"]
} }

View File

@@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.110.1"], "requirements": ["hass-nabucasa==0.111.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -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 path is relative, we assume relative to Home Assistant config dir
if not os.path.isabs(download_path): if not os.path.isabs(download_path):
download_path = hass.config.path(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): if not await hass.async_add_executor_job(os.path.isdir, download_path):
_LOGGER.error( _LOGGER.error(

View File

@@ -11,6 +11,7 @@ import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path 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] entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
download_path = entry.data[CONF_DOWNLOAD_DIR] download_path = entry.data[CONF_DOWNLOAD_DIR]
url: str = service.data[ATTR_URL]
def do_download() -> None: subdir: str | None = service.data.get(ATTR_SUBDIR)
"""Download the file.""" target_filename: str | None = service.data.get(ATTR_FILENAME)
try: overwrite: bool = service.data[ATTR_OVERWRITE]
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: if subdir:
# Check the path # Check the path
try:
raise_if_invalid_path(subdir) 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 final_path = None
filename = target_filename
try:
req = requests.get(url, stream=True, timeout=10) req = requests.get(url, stream=True, timeout=10)
if req.status_code != HTTPStatus.OK: if req.status_code != HTTPStatus.OK:

View File

@@ -12,6 +12,14 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" "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": { "services": {
"download_file": { "download_file": {
"name": "Download file", "name": "Download file",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "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"]
} }

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250731.0"] "requirements": ["home-assistant-frontend==20250805.0"]
} }

View File

@@ -123,10 +123,10 @@
}, },
"ai_task_data": { "ai_task_data": {
"initiate_flow": { "initiate_flow": {
"user": "Add Generate data with AI service", "user": "Add AI task",
"reconfigure": "Reconfigure Generate data with AI service" "reconfigure": "Reconfigure AI task"
}, },
"entry_type": "Generate data with AI service", "entry_type": "AI task",
"step": { "step": {
"set_options": { "set_options": {
"data": { "data": {

View File

@@ -226,6 +226,10 @@
"unsupported_virtualization_image": { "unsupported_virtualization_image": {
"title": "Unsupported system - Incorrect OS image for virtualization", "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." "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": { "entity": {

View File

@@ -21,6 +21,20 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1 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) @dataclass(frozen=True, kw_only=True)
class AutomowerButtonEntityDescription(ButtonEntityDescription): class AutomowerButtonEntityDescription(ButtonEntityDescription):
"""Describes Automower button entities.""" """Describes Automower button entities."""
@@ -28,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription):
available_fn: Callable[[MowerAttributes], bool] = lambda _: True available_fn: Callable[[MowerAttributes], bool] = lambda _: True
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
press_fn: Callable[[AutomowerSession, str], Awaitable[Any]] press_fn: Callable[[AutomowerSession, str], Awaitable[Any]]
poll_after_sending: bool = False
MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
@@ -43,6 +58,14 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
translation_key="sync_clock", translation_key="sync_clock",
press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), 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: async def async_press(self) -> None:
"""Send a command to the mower.""" """Send a command to the mower."""
await self.entity_description.press_fn(self.coordinator.api, self.mower_id) 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()

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
from typing import override from typing import override
@@ -14,7 +14,7 @@ from aioautomower.exceptions import (
HusqvarnaTimeoutError, HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError, HusqvarnaWSServerHandshakeError,
) )
from aioautomower.model import MowerDictionary from aioautomower.model import MowerDictionary, MowerStates
from aioautomower.session import AutomowerSession from aioautomower.session import AutomowerSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600 MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8) SCAN_INTERVAL = timedelta(minutes=8)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time 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] type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
@@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
self.new_areas_callbacks: list[Callable[[str, set[int]], 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 @override
@callback @callback
@@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
await self.api.connect() await self.api.connect()
self.api.register_data_callback(self.handle_websocket_updates) self.api.register_data_callback(self.handle_websocket_updates)
self.ws_connected = True 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: try:
data = await self.api.get_status() data = await self.api.get_status()
except ApiError as err: except ApiError as err:
@@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
mower_data.capabilities.work_areas for mower_data in self.data.values() mower_data.capabilities.work_areas for mower_data in self.data.values()
): ):
self._async_add_remove_work_areas() 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 @callback
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
@@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"reconnect_task", "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: def _async_add_remove_devices(self) -> None:
"""Add new devices and remove orphaned devices from the registry.""" """Add new devices and remove orphaned devices from the registry."""
current_devices = set(self.data) current_devices = set(self.data)

View File

@@ -8,6 +8,9 @@
"button": { "button": {
"sync_clock": { "sync_clock": {
"default": "mdi:clock-check-outline" "default": "mdi:clock-check-outline"
},
"reset_cutting_blade_usage_time": {
"default": "mdi:saw-blade"
} }
}, },
"number": { "number": {

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioautomower"], "loggers": ["aioautomower"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aioautomower==2.1.1"] "requirements": ["aioautomower==2.1.2"]
} }

View File

@@ -53,6 +53,9 @@
}, },
"sync_clock": { "sync_clock": {
"name": "Sync clock" "name": "Sync clock"
},
"reset_cutting_blade_usage_time": {
"name": "Reset cutting blade usage time"
} }
}, },
"number": { "number": {

View File

@@ -13,7 +13,7 @@
"requirements": [ "requirements": [
"xknx==3.8.0", "xknx==3.8.0",
"xknxproject==3.8.2", "xknxproject==3.8.2",
"knx-frontend==2025.7.23.50952" "knx-frontend==2025.8.4.154919"
], ],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -285,7 +285,9 @@ DISCOVERY_SCHEMAS = [
native_min_value=0.5, native_min_value=0.5,
native_step=0.5, native_step=0.5,
device_to_ha=( 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.5100.0% ha_to_device=lambda x: round(x * 2), # HA range 0.5100.0%
mode=NumberMode.SLIDER, mode=NumberMode.SLIDER,

View File

@@ -130,6 +130,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
list_id=self._shopping_list_id, list_id=self._shopping_list_id,
note=item.summary.strip() if item.summary else item.summary, note=item.summary.strip() if item.summary else item.summary,
position=position, position=position,
quantity=0.0,
) )
try: try:
await self.coordinator.client.add_shopping_item(new_shopping_item) await self.coordinator.client.add_shopping_item(new_shopping_item)

View File

@@ -11,7 +11,7 @@
} }
}, },
"triggers": { "triggers": {
"mqtt": { "_": {
"trigger": "mdi:swap-horizontal" "trigger": "mdi:swap-horizontal"
} }
} }

View File

@@ -1285,7 +1285,7 @@
} }
}, },
"triggers": { "triggers": {
"mqtt": { "_": {
"name": "MQTT", "name": "MQTT",
"description": "When a specific message is received on a given MQTT topic.", "description": "When a specific message is received on a given MQTT topic.",
"description_configured": "When an MQTT message has been received", "description_configured": "When an MQTT message has been received",

View File

@@ -1,6 +1,6 @@
# Describes the format for MQTT triggers # Describes the format for MQTT triggers
mqtt: _:
fields: fields:
payload: payload:
example: "on" example: "on"

View File

@@ -58,10 +58,10 @@
}, },
"ai_task_data": { "ai_task_data": {
"initiate_flow": { "initiate_flow": {
"user": "Add Generate data with AI service", "user": "Add AI task",
"reconfigure": "Reconfigure Generate data with AI service" "reconfigure": "Reconfigure AI task"
}, },
"entry_type": "Generate data with AI service", "entry_type": "AI task",
"step": { "step": {
"set_options": { "set_options": {
"data": { "data": {

View File

@@ -52,9 +52,9 @@
} }
}, },
"initiate_flow": { "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": { "abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"

View File

@@ -73,10 +73,10 @@
}, },
"ai_task_data": { "ai_task_data": {
"initiate_flow": { "initiate_flow": {
"user": "Add Generate data with AI service", "user": "Add AI task",
"reconfigure": "Reconfigure Generate data with AI service" "reconfigure": "Reconfigure AI task"
}, },
"entry_type": "Generate data with AI service", "entry_type": "AI task",
"step": { "step": {
"init": { "init": {
"data": { "data": {

View File

@@ -422,9 +422,7 @@ class ReolinkVODMediaSource(MediaSource):
file_name = f"{file.start_time.time()} {file.duration}" file_name = f"{file.start_time.time()} {file.duration}"
if file.triggers != file.triggers.NONE: if file.triggers != file.triggers.NONE:
file_name += " " + " ".join( file_name += " " + " ".join(
str(trigger.name).title() str(trigger.name).title() for trigger in file.triggers
for trigger in file.triggers
if trigger != trigger.NONE
) )
children.append( children.append(

View File

@@ -116,6 +116,7 @@ NUMBER_ENTITIES = (
cmd_id=[289, 438], cmd_id=[289, 438],
translation_key="floodlight_brightness", translation_key="floodlight_brightness",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_step=1, native_step=1,
native_min_value=1, native_min_value=1,
native_max_value=100, native_max_value=100,
@@ -407,8 +408,8 @@ NUMBER_ENTITIES = (
key="auto_track_limit_left", key="auto_track_limit_left",
cmd_key="GetPtzTraceSection", cmd_key="GetPtzTraceSection",
translation_key="auto_track_limit_left", translation_key="auto_track_limit_left",
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_step=1, native_step=1,
native_min_value=-1, native_min_value=-1,
native_max_value=2700, native_max_value=2700,
@@ -420,8 +421,8 @@ NUMBER_ENTITIES = (
key="auto_track_limit_right", key="auto_track_limit_right",
cmd_key="GetPtzTraceSection", cmd_key="GetPtzTraceSection",
translation_key="auto_track_limit_right", translation_key="auto_track_limit_right",
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_step=1, native_step=1,
native_min_value=-1, native_min_value=-1,
native_max_value=2700, native_max_value=2700,
@@ -435,6 +436,7 @@ NUMBER_ENTITIES = (
translation_key="auto_track_disappear_time", translation_key="auto_track_disappear_time",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1, native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=1, native_min_value=1,
@@ -451,6 +453,7 @@ NUMBER_ENTITIES = (
translation_key="auto_track_stop_time", translation_key="auto_track_stop_time",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1, native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=1, native_min_value=1,

View File

@@ -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": { "options": {
"step": { "step": {
"alarm_control_panel": { "alarm_control_panel": {

View File

@@ -34,11 +34,16 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError 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 ( from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback, AddConfigEntryEntitiesCallback,
AddEntitiesCallback, AddEntitiesCallback,
) )
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN 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): class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
"""Representation of a template vacuum features.""" """Representation of a template vacuum features."""
@@ -369,6 +394,16 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum):
self.add_script(action_id, action_config, name, DOMAIN) self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature 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 @callback
def _async_setup_templates(self) -> None: def _async_setup_templates(self) -> None:
"""Set up templates.""" """Set up templates."""
@@ -434,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
self._to_render_simple.append(key) self._to_render_simple.append(key)
self._parse_result.add(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 @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle update of the data.""" """Handle update of the data."""

View File

@@ -314,6 +314,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
), ),
TAMPER_BINARY_SENSOR, 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 # Thermostatic Radiator Valve
# Not documented # Not documented
"wkf": ( "wkf": (

View File

@@ -109,6 +109,7 @@ class DPCode(StrEnum):
ANION = "anion" # Ionizer unit ANION = "anion" # Ionizer unit
ARM_DOWN_PERCENT = "arm_down_percent" ARM_DOWN_PERCENT = "arm_down_percent"
ARM_UP_PERCENT = "arm_up_percent" ARM_UP_PERCENT = "arm_up_percent"
ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API
BASIC_ANTI_FLICKER = "basic_anti_flicker" BASIC_ANTI_FLICKER = "basic_anti_flicker"
BASIC_DEVICE_VOLUME = "basic_device_volume" BASIC_DEVICE_VOLUME = "basic_device_volume"
BASIC_FLIP = "basic_flip" BASIC_FLIP = "basic_flip"
@@ -215,6 +216,10 @@ class DPCode(StrEnum):
HUMIDITY = "humidity" # Humidity HUMIDITY = "humidity" # Humidity
HUMIDITY_CURRENT = "humidity_current" # Current humidity HUMIDITY_CURRENT = "humidity_current" # Current humidity
HUMIDITY_INDOOR = "humidity_indoor" # Indoor 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_SET = "humidity_set" # Humidity setting
HUMIDITY_VALUE = "humidity_value" # Humidity HUMIDITY_VALUE = "humidity_value" # Humidity
IPC_WORK_MODE = "ipc_work_mode" IPC_WORK_MODE = "ipc_work_mode"
@@ -360,6 +365,15 @@ class DPCode(StrEnum):
TEMP_CURRENT_EXTERNAL = ( TEMP_CURRENT_EXTERNAL = (
"temp_current_external" # Current external temperature in Celsius "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 = (
"temp_current_external_f" # Current external temperature in Fahrenheit "temp_current_external_f" # Current external temperature in Fahrenheit
) )
@@ -405,6 +419,7 @@ class DPCode(StrEnum):
WINDOW_CHECK = "window_check" WINDOW_CHECK = "window_check"
WINDOW_STATE = "window_state" WINDOW_STATE = "window_state"
WINDSPEED = "windspeed" WINDSPEED = "windspeed"
WINDSPEED_AVG = "windspeed_avg"
WIRELESS_BATTERYLOCK = "wireless_batterylock" WIRELESS_BATTERYLOCK = "wireless_batterylock"
WIRELESS_ELECTRICITY = "wireless_electricity" WIRELESS_ELECTRICITY = "wireless_electricity"
WORK_MODE = "work_mode" # Working mode WORK_MODE = "work_mode" # Working mode

View File

@@ -267,7 +267,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
return int(self._speed.remap_value_to(value, 1, 100)) return int(self._speed.remap_value_to(value, 1, 100))
if self._speeds is not None: 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 None
return ordered_list_item_to_percentage(self._speeds.range, value) return ordered_list_item_to_percentage(self._speeds.range, value)

View File

@@ -846,6 +846,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, 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( TuyaSensorEntityDescription(
key=DPCode.VA_HUMIDITY, key=DPCode.VA_HUMIDITY,
translation_key="humidity", translation_key="humidity",
@@ -858,12 +879,51 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.HUMIDITY, device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT, 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( TuyaSensorEntityDescription(
key=DPCode.BRIGHT_VALUE, key=DPCode.BRIGHT_VALUE,
translation_key="illuminance", translation_key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
TuyaSensorEntityDescription(
key=DPCode.WINDSPEED_AVG,
translation_key="wind_speed",
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
*BATTERY_SENSORS, *BATTERY_SENSORS,
), ),
# Gas Detector # Gas Detector

View File

@@ -502,9 +502,21 @@
"temperature_external": { "temperature_external": {
"name": "Probe temperature" "name": "Probe temperature"
}, },
"indexed_temperature_external": {
"name": "Probe temperature channel {index}"
},
"humidity": { "humidity": {
"name": "[%key:component::sensor::entity_component::humidity::name%]" "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": { "pm25": {
"name": "[%key:component::sensor::entity_component::pm25::name%]" "name": "[%key:component::sensor::entity_component::pm25::name%]"
}, },

View File

@@ -79,6 +79,8 @@ DEFAULT_NAME = "Vacuum cleaner robot"
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1")
_DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") _DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1")
_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",)
class VacuumEntityFeature(IntFlag): class VacuumEntityFeature(IntFlag):
"""Supported features of the vacuum entity.""" """Supported features of the vacuum entity."""
@@ -321,7 +323,11 @@ class StateVacuumEntity(
Integrations should implement a sensor instead. 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 # Don't report usage until after entity added to hass, after init
report_usage( report_usage(
f"is setting the {property} which has been deprecated." f"is setting the {property} which has been deprecated."
@@ -341,7 +347,11 @@ class StateVacuumEntity(
Integrations should remove the battery supported feature when migrating Integrations should remove the battery supported feature when migrating
battery level and icon to a sensor. 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 # Don't report usage until after entity added to hass, after init
report_usage( report_usage(
f"is setting the battery supported feature which has been deprecated." f"is setting the battery supported feature which has been deprecated."

View File

@@ -13,6 +13,6 @@
"documentation": "https://www.home-assistant.io/integrations/wyoming", "documentation": "https://www.home-assistant.io/integrations/wyoming",
"integration_type": "service", "integration_type": "service",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["wyoming==1.7.1"], "requirements": ["wyoming==1.7.2"],
"zeroconf": ["_wyoming._tcp.local."] "zeroconf": ["_wyoming._tcp.local."]
} }

View File

@@ -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: if node.is_controller_node:
# Create a controller status sensor for each device # Create a controller status sensor for each device
@@ -637,8 +637,8 @@ class ControllerEvents:
f"{DOMAIN}.identify_controller.{dev_id[1]}", f"{DOMAIN}.identify_controller.{dev_id[1]}",
) )
async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None:
"""Check if the node was preprovisioned and update the device registry.""" """Check if the node was pre-provisioned and update the device registry."""
provisioning_entry = ( provisioning_entry = (
await self.driver_events.driver.controller.async_get_provisioning_entry( await self.driver_events.driver.controller.async_get_provisioning_entry(
node.node_id node.node_id
@@ -648,27 +648,35 @@ class ControllerEvents:
provisioning_entry provisioning_entry
and provisioning_entry.additional_properties and provisioning_entry.additional_properties
and "device_id" in provisioning_entry.additional_properties and "device_id" in provisioning_entry.additional_properties
): and (
preprovisioned_device = self.dev_reg.async_get( pre_provisioned_device := self.dev_reg.async_get(
provisioning_entry.additional_properties["device_id"] provisioning_entry.additional_properties["device_id"]
) )
)
if preprovisioned_device: and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}"))
dsk = provisioning_entry.dsk in pre_provisioned_device.identifiers
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 driver = self.driver_events.driver
device_id = get_device_id(driver, node) device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node) device_id_ext = get_device_id_ext(driver, node)
new_identifiers = preprovisioned_device.identifiers.copy() new_identifiers = pre_provisioned_device.identifiers.copy()
new_identifiers.remove(dsk_identifier) new_identifiers.remove(dsk_identifier)
new_identifiers.add(device_id) new_identifiers.add(device_id)
if device_id_ext: if device_id_ext:
new_identifiers.add(device_id_ext) new_identifiers.add(device_id_ext)
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( self.dev_reg.async_update_device(
preprovisioned_device.id, 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, new_identifiers=new_identifiers,
) )

View File

@@ -8,8 +8,8 @@ from homeassistant.helpers.trigger import Trigger
from .triggers import event, value_updated from .triggers import event, value_updated
TRIGGERS = { TRIGGERS = {
event.PLATFORM_TYPE: event.EventTrigger, event.RELATIVE_PLATFORM_TYPE: event.EventTrigger,
value_updated.PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, value_updated.RELATIVE_PLATFORM_TYPE: value_updated.ValueUpdatedTrigger,
} }

View File

@@ -34,8 +34,11 @@ from ..helpers import (
) )
from .trigger_helpers import async_bypass_dynamic_config_validation 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 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: def validate_non_node_event_source(obj: dict) -> dict:

View File

@@ -37,8 +37,11 @@ from ..const import (
from ..helpers import async_get_nodes_from_targets, get_device_id from ..helpers import async_get_nodes_from_targets, get_device_id
from .trigger_helpers import async_bypass_dynamic_config_validation 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 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_FROM = "from"
ATTR_TO = "to" ATTR_TO = "to"

View 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]

View File

@@ -644,6 +644,13 @@ def slug(value: Any) -> str:
raise vol.Invalid(f"invalid slug {value} (try {slg})") 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( def schema_with_slug_keys(
value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug
) -> Callable: ) -> Callable:

View File

@@ -40,9 +40,9 @@ from homeassistant.loader import (
from homeassistant.util.async_ import create_eager_task from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml import load_yaml_dict
from homeassistant.util.yaml.loader import JSON_TYPE
from . import config_validation as cv, selector 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 .integration_platform import async_process_integration_platforms
from .selector import TargetSelector from .selector import TargetSelector
from .template import Template from .template import Template
@@ -100,7 +100,7 @@ def starts_with_dot(key: str) -> str:
_TRIGGERS_SCHEMA = vol.Schema( _TRIGGERS_SCHEMA = vol.Schema(
{ {
vol.Remove(vol.All(str, starts_with_dot)): object, 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"): if hasattr(platform, "async_get_triggers"):
for trigger_key in await platform.async_get_triggers(hass): 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 hass.data[TRIGGERS][trigger_key] = integration_domain
new_triggers.add(trigger_key) new_triggers.add(trigger_key)
elif hasattr(platform, "async_validate_trigger_config") or hasattr( elif hasattr(platform, "async_validate_trigger_config") or hasattr(
@@ -357,9 +358,8 @@ class PluggableAction:
async def _async_get_trigger_platform( async def _async_get_trigger_platform(
hass: HomeAssistant, config: ConfigType hass: HomeAssistant, trigger_key: str
) -> TriggerProtocol: ) -> tuple[str, TriggerProtocol]:
trigger_key: str = config[CONF_PLATFORM]
platform_and_sub_type = trigger_key.split(".") platform_and_sub_type = trigger_key.split(".")
platform = platform_and_sub_type[0] platform = platform_and_sub_type[0]
platform = _PLATFORM_ALIASES.get(platform, platform) platform = _PLATFORM_ALIASES.get(platform, platform)
@@ -368,7 +368,7 @@ async def _async_get_trigger_platform(
except IntegrationNotFound: except IntegrationNotFound:
raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None
try: try:
return await integration.async_get_platform("trigger") return platform, await integration.async_get_platform("trigger")
except ImportError: except ImportError:
raise vol.Invalid( raise vol.Invalid(
f"Integration '{platform}' does not provide trigger support" f"Integration '{platform}' does not provide trigger support"
@@ -381,11 +381,14 @@ async def async_validate_trigger_config(
"""Validate triggers.""" """Validate triggers."""
config = [] config = []
for conf in trigger_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"): if hasattr(platform, "async_get_triggers"):
trigger_descriptors = await platform.async_get_triggers(hass) trigger_descriptors = await platform.async_get_triggers(hass)
trigger_key: str = conf[CONF_PLATFORM] relative_trigger_key = get_relative_description_key(
if not (trigger := trigger_descriptors.get(trigger_key)): platform_domain, trigger_key
)
if not (trigger := trigger_descriptors.get(relative_trigger_key)):
raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified")
conf = await trigger.async_validate_trigger_config(hass, conf) conf = await trigger.async_validate_trigger_config(hass, conf)
elif hasattr(platform, "async_validate_trigger_config"): elif hasattr(platform, "async_validate_trigger_config"):
@@ -471,7 +474,8 @@ async def async_initialize_triggers(
if not enabled: if not enabled:
continue 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_id = conf.get(CONF_ID, f"{idx}")
trigger_idx = f"{idx}" trigger_idx = f"{idx}"
trigger_alias = conf.get(CONF_ALIAS) trigger_alias = conf.get(CONF_ALIAS)
@@ -487,7 +491,10 @@ async def async_initialize_triggers(
action_wrapper = _trigger_action_wrapper(hass, action, conf) action_wrapper = _trigger_action_wrapper(hass, action, conf)
if hasattr(platform, "async_get_triggers"): if hasattr(platform, "async_get_triggers"):
trigger_descriptors = await platform.async_get_triggers(hass) 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) coro = trigger.async_attach_trigger(action_wrapper, info)
else: else:
coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) coro = platform.async_attach_trigger(hass, conf, action_wrapper, info)
@@ -525,11 +532,11 @@ async def async_initialize_triggers(
return remove_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.""" """Load triggers file for an integration."""
try: try:
return cast( return cast(
JSON_TYPE, dict[str, Any],
_TRIGGERS_SCHEMA( _TRIGGERS_SCHEMA(
load_yaml_dict(str(integration.file_path / "triggers.yaml")) 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( def _load_triggers_files(
hass: HomeAssistant, integrations: Iterable[Integration] integrations: Iterable[Integration],
) -> dict[str, JSON_TYPE]: ) -> dict[str, dict[str, Any]]:
"""Load trigger files for multiple integrations.""" """Load trigger files for multiple integrations."""
return { 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 for integration in integrations
} }
@@ -574,7 +584,7 @@ async def async_get_all_descriptions(
return descriptions_cache return descriptions_cache
# Files we loaded for missing descriptions # 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, # 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 # but now we must make a copy in case new triggers get added
# while we are loading the missing ones so we do not # while we are loading the missing ones so we do not
@@ -601,7 +611,7 @@ async def async_get_all_descriptions(
if integrations: if integrations:
new_triggers_descriptions = await hass.async_add_executor_job( 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 # 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] domain = triggers[missing_trigger]
if ( if (
yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr] yaml_description := new_triggers_descriptions.get(domain, {}).get(
missing_trigger missing_trigger
) )
) is None: ) is None:

View File

@@ -35,10 +35,10 @@ fnv-hash-fast==1.5.0
go2rtc-client==0.2.1 go2rtc-client==0.2.1
ha-ffmpeg==3.2.2 ha-ffmpeg==3.2.2
habluetooth==4.0.1 habluetooth==4.0.1
hass-nabucasa==0.110.1 hass-nabucasa==0.111.0
hassil==2.2.3 hassil==2.2.3
home-assistant-bluetooth==1.13.1 home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250731.0 home-assistant-frontend==20250805.0
home-assistant-intents==2025.7.30 home-assistant-intents==2025.7.30
httpx==0.28.1 httpx==0.28.1
ifaddr==0.2.0 ifaddr==0.2.0

View File

@@ -47,7 +47,7 @@ dependencies = [
"fnv-hash-fast==1.5.0", "fnv-hash-fast==1.5.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud # hass-nabucasa is imported by helpers which don't depend on the cloud
# integration # integration
"hass-nabucasa==0.110.1", "hass-nabucasa==0.111.0",
# When bumping httpx, please check the version pins of # When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all # httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1", "httpx==0.28.1",

2
requirements.txt generated
View File

@@ -22,7 +22,7 @@ certifi>=2021.5.30
ciso8601==2.3.2 ciso8601==2.3.2
cronsim==2.6 cronsim==2.6
fnv-hash-fast==1.5.0 fnv-hash-fast==1.5.0
hass-nabucasa==0.110.1 hass-nabucasa==0.111.0
httpx==0.28.1 httpx==0.28.1
home-assistant-bluetooth==1.13.1 home-assistant-bluetooth==1.13.1
ifaddr==0.2.0 ifaddr==0.2.0

16
requirements_all.txt generated
View File

@@ -204,7 +204,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0 aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower # homeassistant.components.husqvarna_automower
aioautomower==2.1.1 aioautomower==2.1.2
# homeassistant.components.azure_devops # homeassistant.components.azure_devops
aioazuredevops==2.2.1 aioazuredevops==2.2.1
@@ -453,7 +453,7 @@ airgradient==0.9.2
airly==1.1.0 airly==1.1.0
# homeassistant.components.airos # homeassistant.components.airos
airos==0.2.1 airos==0.2.4
# homeassistant.components.airthings_ble # homeassistant.components.airthings_ble
airthings-ble==0.9.2 airthings-ble==0.9.2
@@ -777,7 +777,7 @@ decora-wifi==1.4
# decora==0.6 # decora==0.6
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==13.5.0 deebot-client==13.6.0
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@@ -1133,7 +1133,7 @@ habiticalib==0.4.1
habluetooth==4.0.1 habluetooth==4.0.1
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.110.1 hass-nabucasa==0.111.0
# homeassistant.components.splunk # homeassistant.components.splunk
hass-splunk==0.1.1 hass-splunk==0.1.1
@@ -1174,7 +1174,7 @@ hole==0.9.0
holidays==0.77 holidays==0.77
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20250731.0 home-assistant-frontend==20250805.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.7.30 home-assistant-intents==2025.7.30
@@ -1216,7 +1216,7 @@ ibmiotf==0.3.4
ical==11.0.0 ical==11.0.0
# homeassistant.components.caldav # homeassistant.components.caldav
icalendar==6.1.0 icalendar==6.3.1
# homeassistant.components.ping # homeassistant.components.ping
icmplib==3.0 icmplib==3.0
@@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1
knocki==0.4.2 knocki==0.4.2
# homeassistant.components.knx # homeassistant.components.knx
knx-frontend==2025.7.23.50952 knx-frontend==2025.8.4.154919
# homeassistant.components.konnected # homeassistant.components.konnected
konnected==1.2.0 konnected==1.2.0
@@ -3133,7 +3133,7 @@ wolf-comm==0.0.23
wsdot==0.0.1 wsdot==0.0.1
# homeassistant.components.wyoming # homeassistant.components.wyoming
wyoming==1.7.1 wyoming==1.7.2
# homeassistant.components.xbox # homeassistant.components.xbox
xbox-webapi==2.1.0 xbox-webapi==2.1.0

View File

@@ -13,7 +13,7 @@ freezegun==1.5.2
go2rtc-client==0.2.1 go2rtc-client==0.2.1
license-expression==30.4.3 license-expression==30.4.3
mock-open==1.4.0 mock-open==1.4.0
mypy-dev==1.18.0a3 mypy-dev==1.18.0a4
pre-commit==4.2.0 pre-commit==4.2.0
pydantic==2.11.7 pydantic==2.11.7
pylint==3.3.7 pylint==3.3.7

View File

@@ -192,7 +192,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0 aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower # homeassistant.components.husqvarna_automower
aioautomower==2.1.1 aioautomower==2.1.2
# homeassistant.components.azure_devops # homeassistant.components.azure_devops
aioazuredevops==2.2.1 aioazuredevops==2.2.1
@@ -435,7 +435,7 @@ airgradient==0.9.2
airly==1.1.0 airly==1.1.0
# homeassistant.components.airos # homeassistant.components.airos
airos==0.2.1 airos==0.2.4
# homeassistant.components.airthings_ble # homeassistant.components.airthings_ble
airthings-ble==0.9.2 airthings-ble==0.9.2
@@ -677,7 +677,7 @@ debugpy==1.8.14
# decora==0.6 # decora==0.6
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==13.5.0 deebot-client==13.6.0
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@@ -994,7 +994,7 @@ habiticalib==0.4.1
habluetooth==4.0.1 habluetooth==4.0.1
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.110.1 hass-nabucasa==0.111.0
# homeassistant.components.assist_satellite # homeassistant.components.assist_satellite
# homeassistant.components.conversation # homeassistant.components.conversation
@@ -1023,7 +1023,7 @@ hole==0.9.0
holidays==0.77 holidays==0.77
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20250731.0 home-assistant-frontend==20250805.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.7.30 home-assistant-intents==2025.7.30
@@ -1056,7 +1056,7 @@ ibeacon-ble==1.2.0
ical==11.0.0 ical==11.0.0
# homeassistant.components.caldav # homeassistant.components.caldav
icalendar==6.1.0 icalendar==6.3.1
# homeassistant.components.ping # homeassistant.components.ping
icmplib==3.0 icmplib==3.0
@@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0
knocki==0.4.2 knocki==0.4.2
# homeassistant.components.knx # homeassistant.components.knx
knx-frontend==2025.7.23.50952 knx-frontend==2025.8.4.154919
# homeassistant.components.konnected # homeassistant.components.konnected
konnected==1.2.0 konnected==1.2.0
@@ -2586,7 +2586,7 @@ wolf-comm==0.0.23
wsdot==0.0.1 wsdot==0.0.1
# homeassistant.components.wyoming # homeassistant.components.wyoming
wyoming==1.7.1 wyoming==1.7.2
# homeassistant.components.xbox # homeassistant.components.xbox
xbox-webapi==2.1.0 xbox-webapi==2.1.0

View File

@@ -136,7 +136,7 @@ TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
vol.Optional("trigger"): icon_value_validator, vol.Optional("trigger"): icon_value_validator,
} }
), ),
slug_validator=translation_key_validator, slug_validator=cv.underscore_slug,
) )

View File

@@ -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=translation_key_validator, slug_validator=cv.underscore_slug,
), ),
vol.Optional("conversation"): { vol.Optional("conversation"): {
vol.Required("agent"): { vol.Required("agent"): {

View File

@@ -50,7 +50,7 @@ TRIGGER_SCHEMA = vol.Any(
TRIGGERS_SCHEMA = vol.Schema( TRIGGERS_SCHEMA = vol.Schema(
{ {
vol.Remove(vol.All(str, trigger.starts_with_dot)): object, vol.Remove(vol.All(str, trigger.starts_with_dot)): object,
cv.slug: TRIGGER_SCHEMA, cv.underscore_slug: TRIGGER_SCHEMA,
} }
) )

View File

@@ -2,7 +2,7 @@
import asyncio import asyncio
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
@@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away(
assert hass_storage[auth_store.STORAGE_KEY] != {} 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( async def test_add_remove_user_affects_tokens(
hass: HomeAssistant, hass_storage: dict[str, Any] hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None: ) -> None:

View File

@@ -439,64 +439,6 @@
'state': '5500', '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] # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@@ -4,9 +4,9 @@ from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from airos.exceptions import ( from airos.exceptions import (
ConnectionAuthenticationError, AirOSConnectionAuthenticationError,
DeviceConnectionError, AirOSDeviceConnectionError,
KeyDataMissingError, AirOSKeyDataMissingError,
) )
import pytest import pytest
@@ -78,9 +78,9 @@ async def test_form_duplicate_entry(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception", "error"), ("exception", "error"),
[ [
(ConnectionAuthenticationError, "invalid_auth"), (AirOSConnectionAuthenticationError, "invalid_auth"),
(DeviceConnectionError, "cannot_connect"), (AirOSDeviceConnectionError, "cannot_connect"),
(KeyDataMissingError, "key_data_missing"), (AirOSKeyDataMissingError, "key_data_missing"),
(Exception, "unknown"), (Exception, "unknown"),
], ],
) )

View File

@@ -4,9 +4,9 @@ from datetime import timedelta
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from airos.exceptions import ( from airos.exceptions import (
ConnectionAuthenticationError, AirOSConnectionAuthenticationError,
DataMissingError, AirOSDataMissingError,
DeviceConnectionError, AirOSDeviceConnectionError,
) )
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
@@ -39,10 +39,10 @@ async def test_all_entities(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception"), ("exception"),
[ [
ConnectionAuthenticationError, AirOSConnectionAuthenticationError,
TimeoutError, TimeoutError,
DeviceConnectionError, AirOSDeviceConnectionError,
DataMissingError, AirOSDataMissingError,
], ],
) )
async def test_sensor_update_exception_handling( async def test_sensor_update_exception_handling(

View 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

View File

@@ -1,6 +1,8 @@
"""Tests for the downloader component init.""" """Tests for the downloader component init."""
from unittest.mock import patch from pathlib import Path
import pytest
from homeassistant.components.downloader.const import ( from homeassistant.components.downloader.const import (
CONF_DOWNLOAD_DIR, CONF_DOWNLOAD_DIR,
@@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_initialization(hass: HomeAssistant) -> None: @pytest.fixture
"""Test the initialization of the downloader component.""" def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
config_entry = MockConfigEntry( """Return a download directory."""
domain=DOMAIN, if hasattr(request, "param"):
data={ return tmp_path / request.param
CONF_DOWNLOAD_DIR: "/test_dir", return tmp_path
},
)
config_entry.add_to_hass(hass) async def test_config_entry_setup(
with patch("os.path.isdir", return_value=True): hass: HomeAssistant, setup_integration: MockConfigEntry
assert await hass.config_entries.async_setup(config_entry.entry_id) ) -> None:
"""Test config entry setup."""
config_entry = setup_integration
assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE)
assert config_entry.state is ConfigEntryState.LOADED 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

View 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()

View File

@@ -47,6 +47,54 @@
'state': 'unavailable', '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] # name: test_button_snapshot[button.test_mower_1_sync_clock-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@@ -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)) @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, hass: HomeAssistant,
mock_automower_client: AsyncMock, mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
@@ -58,42 +58,43 @@ async def test_button_states_and_commands(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.state == STATE_UNKNOWN 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)) @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, hass: HomeAssistant,
mock_automower_client: AsyncMock, mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
values: dict[str, MowerAttributes], values: dict[str, MowerAttributes],
entity_id: str,
name: str,
expected_command: str,
) -> None: ) -> None:
"""Test sync clock button command.""" """Test Automower button commands."""
entity_id = "button.test_mower_1_sync_clock" values[TEST_MOWER_ID].mower.is_error_confirmable = True
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get(entity_id) 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 mock_automower_client.get_status.return_value = values
@@ -103,11 +104,15 @@ async def test_sync_clock(
{ATTR_ENTITY_ID: entity_id}, {ATTR_ENTITY_ID: entity_id},
blocking=True, 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() await hass.async_block_till_done()
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.state == "2024-02-29T11:00:00+00:00" 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( with pytest.raises(
HomeAssistantError, HomeAssistantError,
match="Failed to send command: Test error", match="Failed to send command: Test error",

View File

@@ -14,7 +14,7 @@ from aioautomower.exceptions import (
HusqvarnaTimeoutError, HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError, HusqvarnaWSServerHandshakeError,
) )
from aioautomower.model import Calendar, MowerAttributes, WorkArea from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@@ -484,3 +484,212 @@ async def test_add_and_remove_work_area(
- ADDITIONAL_NUMBER_ENTITIES - ADDITIONAL_NUMBER_ENTITIES
- ADDITIONAL_SENSOR_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

View File

@@ -203,7 +203,7 @@
"1/6/65528": [], "1/6/65528": [],
"1/6/65529": [0, 1, 2], "1/6/65529": [0, 1, 2],
"1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531],
"1/8/0": 254, "1/8/0": 200,
"1/8/15": 0, "1/8/15": 0,
"1/8/17": 0, "1/8/17": 0,
"1/8/65532": 0, "1/8/65532": 0,

View File

@@ -2189,7 +2189,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '127.0', 'state': '100.0',
}) })
# --- # ---
# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] # name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry]

View File

@@ -172,7 +172,7 @@ async def test_pump_level(
# CurrentLevel on LevelControl cluster # CurrentLevel on LevelControl cluster
state = hass.states.get("number.mock_pump_setpoint") state = hass.states.get("number.mock_pump_setpoint")
assert state assert state
assert state.state == "127.0" assert state.state == "100.0"
set_node_attribute(matter_node, 1, 8, 0, 100) set_node_attribute(matter_node, 1, 8, 0, 100)
await trigger_subscription_callback(hass, matter_client) await trigger_subscription_callback(hass, matter_client)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator from collections.abc import AsyncGenerator, Generator
import json import json
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -130,6 +130,28 @@ def mock_smile_config_flow() -> Generator[MagicMock]:
yield api 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 @pytest.fixture
def mock_smile_adam() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]:
"""Create a Mock Adam environment for testing exceptions.""" """Create a Mock Adam environment for testing exceptions."""

View 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',
})
# ---

View File

@@ -2,32 +2,34 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from homeassistant.components.button import ( import pytest
DOMAIN as BUTTON_DOMAIN, from syrupy.assertion import SnapshotAssertion
SERVICE_PRESS,
ButtonDeviceClass, from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
) from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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 hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry
) -> None: ) -> None:
"""Test creation of button entities.""" """Test pressing of button entity."""
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"
await hass.services.async_call( await hass.services.async_call(
BUTTON_DOMAIN, BUTTON_DOMAIN,
SERVICE_PRESS, SERVICE_PRESS,

View File

@@ -90,8 +90,8 @@
'null': 5, 'null': 5,
}), }),
'GetAiCfg': dict({ 'GetAiCfg': dict({
'0': 4, '0': 2,
'null': 4, 'null': 2,
}), }),
'GetAudioAlarm': dict({ 'GetAudioAlarm': dict({
'0': 1, '0': 1,
@@ -177,10 +177,6 @@
'0': 2, '0': 2,
'null': 2, 'null': 2,
}), }),
'GetPtzTraceSection': dict({
'0': 2,
'null': 2,
}),
'GetPush': dict({ 'GetPush': dict({
'0': 1, '0': 1,
'null': 2, 'null': 2,
@@ -196,8 +192,8 @@
'null': 1, 'null': 1,
}), }),
'GetWhiteLed': dict({ 'GetWhiteLed': dict({
'0': 3, '0': 2,
'null': 3, 'null': 2,
}), }),
'GetZoomFocus': dict({ 'GetZoomFocus': dict({
'0': 2, '0': 2,

View File

@@ -15,7 +15,7 @@ from homeassistant.components.vacuum import (
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError 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.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@@ -589,6 +589,40 @@ async def test_battery_level_template(
_verify(hass, STATE_UNKNOWN, expected) _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( @pytest.mark.parametrize(
("count", "state_template", "extra_config"), ("count", "state_template", "extra_config"),
[ [

View File

@@ -41,6 +41,11 @@ DEVICE_MOCKS = {
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
], ],
"cs_qhxmvae667uap4zh": [
# https://github.com/home-assistant/core/issues/141278
Platform.FAN,
Platform.HUMIDIFIER,
],
"cs_vmxuxszzjwp5smli": [ "cs_vmxuxszzjwp5smli": [
# https://github.com/home-assistant/core/issues/119865 # https://github.com/home-assistant/core/issues/119865
Platform.FAN, Platform.FAN,
@@ -214,6 +219,16 @@ DEVICE_MOCKS = {
# https://github.com/home-assistant/core/issues/143499 # https://github.com/home-assistant/core/issues/143499
Platform.SENSOR, 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": [ "gyd_lgekqfxdabipm3tn": [
# https://github.com/home-assistant/core/issues/133173 # https://github.com/home-assistant/core/issues/133173
Platform.LIGHT, Platform.LIGHT,
@@ -227,6 +242,11 @@ DEVICE_MOCKS = {
# https://github.com/home-assistant/core/issues/148347 # https://github.com/home-assistant/core/issues/148347
Platform.SWITCH, Platform.SWITCH,
], ],
"kj_CAjWAxBUZt7QZHfz": [
# https://github.com/home-assistant/core/issues/146023
Platform.FAN,
Platform.SWITCH,
],
"kj_yrzylxax1qspdgpp": [ "kj_yrzylxax1qspdgpp": [
# https://github.com/orgs/home-assistant/discussions/61 # https://github.com/orgs/home-assistant/discussions/61
Platform.FAN, Platform.FAN,
@@ -294,6 +314,15 @@ DEVICE_MOCKS = {
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.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": [ "sfkzq_o6dagifntoafakst": [
# https://github.com/home-assistant/core/issues/148116 # https://github.com/home-assistant/core/issues/148116
Platform.SWITCH, Platform.SWITCH,
@@ -306,16 +335,27 @@ DEVICE_MOCKS = {
], ],
"sp_drezasavompxpcgm": [ "sp_drezasavompxpcgm": [
# https://github.com/home-assistant/core/issues/149704 # https://github.com/home-assistant/core/issues/149704
Platform.CAMERA,
Platform.LIGHT, Platform.LIGHT,
Platform.SELECT, Platform.SELECT,
Platform.SWITCH, Platform.SWITCH,
], ],
"sp_rjKXWRohlvOTyLBu": [ "sp_rjKXWRohlvOTyLBu": [
# https://github.com/home-assistant/core/issues/149704 # https://github.com/home-assistant/core/issues/149704
Platform.CAMERA,
Platform.LIGHT, Platform.LIGHT,
Platform.SELECT, Platform.SELECT,
Platform.SWITCH, 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": [ "tdq_cq1p0nt0a4rixnex": [
# https://github.com/home-assistant/core/issues/146845 # https://github.com/home-assistant/core/issues/146845
Platform.SELECT, Platform.SELECT,
@@ -335,6 +375,10 @@ DEVICE_MOCKS = {
Platform.CLIMATE, Platform.CLIMATE,
Platform.SWITCH, Platform.SWITCH,
], ],
"wg2_nwxr8qcu4seltoro": [
# https://github.com/orgs/home-assistant/discussions/430
Platform.BINARY_SENSOR,
],
"wk_fi6dne5tu4t1nm6j": [ "wk_fi6dne5tu4t1nm6j": [
# https://github.com/orgs/home-assistant/discussions/243 # https://github.com/orgs/home-assistant/discussions/243
Platform.CLIMATE, Platform.CLIMATE,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1729466466688hgsTp2", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaus.com", "endpoint": "https://apigw.tuyaus.com",
"terminal_id": "1732306182276g6jQLp", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "mock_terminal_id", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View 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
}

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "mock_terminal_id", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1750837476328i3TNXQ", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1747045731408d0tb5M", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1751729689584Vh0VoL", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaus.com", "endpoint": "https://apigw.tuyaus.com",
"terminal_id": "1742695000703Ozq34h", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1733006572651YokbqV", "terminal_id": "REDACTED",
"mqtt_connected": null, "mqtt_connected": null,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View 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
}

View 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
}

View File

@@ -1,10 +1,9 @@
{ {
"endpoint": "https://apigw.tuyaus.com", "endpoint": "https://apigw.tuyaus.com",
"terminal_id": "1732306182276g6jQLp", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,
"id": "eb3e988f33c233290cfs3l", "id": "eb3e988f33c233290cfs3l",
"name": "Colorful PIR Night Light", "name": "Colorful PIR Night Light",
"category": "gyd", "category": "gyd",

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaus.com", "endpoint": "https://apigw.tuyaus.com",
"terminal_id": "1750526976566fMhqJs", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": true, "disabled_polling": true,

View 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
}

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "CENSORED", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "mock_terminal_id", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "mock_terminal_id", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1737479380414pasuj4", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1751921699759JsVujI", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaeu.com", "endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1708196692712PHOeqy", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

View File

@@ -1,6 +1,6 @@
{ {
"endpoint": "https://apigw.tuyaus.com", "endpoint": "https://apigw.tuyaus.com",
"terminal_id": "17421891051898r7yM6", "terminal_id": "REDACTED",
"mqtt_connected": true, "mqtt_connected": true,
"disabled_by": null, "disabled_by": null,
"disabled_polling": false, "disabled_polling": false,

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