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

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@v1.2.4
uses: actions/ai-inference@v1.2.7
with:
model: openai/gpt-4o
system-prompt: |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,13 +69,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
options=WIRELESS_MODE_OPTIONS,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",

View File

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

View File

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

View File

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

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 not os.path.isabs(download_path):
download_path = hass.config.path(download_path)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path}
)
if not await hass.async_add_executor_job(os.path.isdir, download_path):
_LOGGER.error(

View File

@@ -11,6 +11,7 @@ import requests
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
@@ -34,24 +35,33 @@ def download_file(service: ServiceCall) -> None:
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
download_path = entry.data[CONF_DOWNLOAD_DIR]
url: str = service.data[ATTR_URL]
subdir: str | None = service.data.get(ATTR_SUBDIR)
target_filename: str | None = service.data.get(ATTR_FILENAME)
overwrite: bool = service.data[ATTR_OVERWRITE]
if subdir:
# Check the path
try:
raise_if_invalid_path(subdir)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="subdir_invalid",
translation_placeholders={"subdir": subdir},
) from err
if os.path.isabs(subdir):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="subdir_not_relative",
translation_placeholders={"subdir": subdir},
)
def do_download() -> None:
"""Download the file."""
final_path = None
filename = target_filename
try:
url = service.data[ATTR_URL]
subdir = service.data.get(ATTR_SUBDIR)
filename = service.data.get(ATTR_FILENAME)
overwrite = service.data.get(ATTR_OVERWRITE)
if subdir:
# Check the path
raise_if_invalid_path(subdir)
final_path = None
req = requests.get(url, stream=True, timeout=10)
if req.status_code != HTTPStatus.OK:

View File

@@ -12,6 +12,14 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
"exceptions": {
"subdir_invalid": {
"message": "Invalid subdirectory, got: {subdir}"
},
"subdir_not_relative": {
"message": "Subdirectory must be relative, got: {subdir}"
}
},
"services": {
"download_file": {
"name": "Download file",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"]
}

View File

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

View File

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

View File

@@ -226,6 +226,10 @@
"unsupported_virtualization_image": {
"title": "Unsupported system - Incorrect OS image for virtualization",
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this."
},
"unsupported_os_version": {
"title": "Unsupported system - Home Assistant OS version",
"description": "System is unsupported because the Home Assistant OS version in use is not supported. Use the link to learn more and how to fix this."
}
},
"entity": {

View File

@@ -21,6 +21,20 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_reset_cutting_blade_usage_time(
session: AutomowerSession,
mower_id: str,
) -> None:
"""Reset cutting blade usage time."""
await session.commands.reset_cutting_blade_usage_time(mower_id)
def reset_cutting_blade_usage_time_availability(data: MowerAttributes) -> bool:
"""Return True if blade usage time is greater than 0."""
value = data.statistics.cutting_blade_usage_time
return value is not None and value > 0
@dataclass(frozen=True, kw_only=True)
class AutomowerButtonEntityDescription(ButtonEntityDescription):
"""Describes Automower button entities."""
@@ -28,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription):
available_fn: Callable[[MowerAttributes], bool] = lambda _: True
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
press_fn: Callable[[AutomowerSession, str], Awaitable[Any]]
poll_after_sending: bool = False
MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
@@ -43,6 +58,14 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
translation_key="sync_clock",
press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id),
),
AutomowerButtonEntityDescription(
key="reset_cutting_blade_usage_time",
translation_key="reset_cutting_blade_usage_time",
available_fn=reset_cutting_blade_usage_time_availability,
exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None,
press_fn=async_reset_cutting_blade_usage_time,
poll_after_sending=True,
),
)
@@ -93,3 +116,5 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
async def async_press(self) -> None:
"""Send a command to the mower."""
await self.entity_description.press_fn(self.coordinator.api, self.mower_id)
if self.entity_description.poll_after_sending:
await self.coordinator.async_request_refresh()

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import timedelta
from datetime import datetime, timedelta
import logging
from typing import override
@@ -14,7 +14,7 @@ from aioautomower.exceptions import (
HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError,
)
from aioautomower.model import MowerDictionary
from aioautomower.model import MowerDictionary, MowerStates
from aioautomower.session import AutomowerSession
from homeassistant.config_entries import ConfigEntry
@@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
PONG_TIMEOUT = timedelta(seconds=90)
PING_INTERVAL = timedelta(seconds=10)
PING_TIMEOUT = timedelta(seconds=5)
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
@@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
self.pong: datetime | None = None
self.websocket_alive: bool = False
self._watchdog_task: asyncio.Task | None = None
@override
@callback
@@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
await self.api.connect()
self.api.register_data_callback(self.handle_websocket_updates)
self.ws_connected = True
def start_watchdog() -> None:
if self._watchdog_task is not None and not self._watchdog_task.done():
_LOGGER.debug("Cancelling previous watchdog task")
self._watchdog_task.cancel()
self._watchdog_task = self.config_entry.async_create_background_task(
self.hass,
self._pong_watchdog(),
"websocket_watchdog",
)
self.api.register_ws_ready_callback(start_watchdog)
try:
data = await self.api.get_status()
except ApiError as err:
@@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
mower_data.capabilities.work_areas for mower_data in self.data.values()
):
self._async_add_remove_work_areas()
if (
not self._should_poll()
and self.update_interval is not None
and self.websocket_alive
):
_LOGGER.debug("All mowers inactive and websocket alive: stop polling")
self.update_interval = None
if self.update_interval is None and self._should_poll():
_LOGGER.debug(
"Polling re-enabled via WebSocket: at least one mower active"
)
self.update_interval = SCAN_INTERVAL
self.hass.async_create_task(self.async_request_refresh())
@callback
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
@@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"reconnect_task",
)
def _should_poll(self) -> bool:
"""Return True if at least one mower is connected and at least one is not OFF."""
return any(mower.metadata.connected for mower in self.data.values()) and any(
mower.mower.state != MowerStates.OFF for mower in self.data.values()
)
async def _pong_watchdog(self) -> None:
_LOGGER.debug("Watchdog started")
try:
while True:
_LOGGER.debug("Sending ping")
self.websocket_alive = await self.api.send_empty_message()
_LOGGER.debug("Ping result: %s", self.websocket_alive)
await asyncio.sleep(60)
_LOGGER.debug("Websocket alive %s", self.websocket_alive)
if not self.websocket_alive:
_LOGGER.debug("No pong received → restart polling")
if self.update_interval is None:
self.update_interval = SCAN_INTERVAL
await self.async_request_refresh()
except asyncio.CancelledError:
_LOGGER.debug("Watchdog cancelled")
def _async_add_remove_devices(self) -> None:
"""Add new devices and remove orphaned devices from the registry."""
current_devices = set(self.data)

View File

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

View File

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

View File

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

View File

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

View File

@@ -285,7 +285,9 @@ DISCOVERY_SCHEMAS = [
native_min_value=0.5,
native_step=0.5,
device_to_ha=(
lambda x: None if x is None else x / 2 # Matter range (1-200)
lambda x: None
if x is None
else min(x, 200) / 2 # Matter range (1-200, capped at 200)
),
ha_to_device=lambda x: round(x * 2), # HA range 0.5100.0%
mode=NumberMode.SLIDER,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,9 +52,9 @@
}
},
"initiate_flow": {
"user": "Add Generate data with AI service"
"user": "Add AI task"
},
"entry_type": "Generate data with AI service",
"entry_type": "AI task",
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"

View File

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

View File

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

View File

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

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

View File

@@ -34,11 +34,16 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers import (
config_validation as cv,
issue_registry as ir,
template,
)
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
@@ -188,6 +193,26 @@ def async_create_preview_vacuum(
)
def create_issue(
hass: HomeAssistant, supported_features: int, name: str, entity_id: str
) -> None:
"""Create the battery_level issue."""
if supported_features & VacuumEntityFeature.BATTERY:
key = "deprecated_battery_level"
ir.async_create_issue(
hass,
DOMAIN,
f"{key}_{entity_id}",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=key,
translation_placeholders={
"entity_name": name,
"entity_id": entity_id,
},
)
class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
"""Representation of a template vacuum features."""
@@ -369,6 +394,16 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
create_issue(
self.hass,
self._attr_supported_features,
self._attr_name or DEFAULT_NAME,
self.entity_id,
)
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
@@ -434,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
self._to_render_simple.append(key)
self._parse_result.add(key)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
create_issue(
self.hass,
self._attr_supported_features,
self._attr_name or DEFAULT_NAME,
self.entity_id,
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""

View File

@@ -314,6 +314,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Zigbee gateway
# Undocumented
"wg2": (
TuyaBinarySensorEntityDescription(
key=DPCode.MASTER_STATE,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
on_value="alarm",
),
),
# Thermostatic Radiator Valve
# Not documented
"wkf": (

View File

@@ -109,6 +109,7 @@ class DPCode(StrEnum):
ANION = "anion" # Ionizer unit
ARM_DOWN_PERCENT = "arm_down_percent"
ARM_UP_PERCENT = "arm_up_percent"
ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API
BASIC_ANTI_FLICKER = "basic_anti_flicker"
BASIC_DEVICE_VOLUME = "basic_device_volume"
BASIC_FLIP = "basic_flip"
@@ -215,6 +216,10 @@ class DPCode(StrEnum):
HUMIDITY = "humidity" # Humidity
HUMIDITY_CURRENT = "humidity_current" # Current humidity
HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity
HUMIDITY_OUTDOOR = "humidity_outdoor" # Outdoor humidity
HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" # Outdoor humidity
HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" # Outdoor humidity
HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity
HUMIDITY_SET = "humidity_set" # Humidity setting
HUMIDITY_VALUE = "humidity_value" # Humidity
IPC_WORK_MODE = "ipc_work_mode"
@@ -360,6 +365,15 @@ class DPCode(StrEnum):
TEMP_CURRENT_EXTERNAL = (
"temp_current_external" # Current external temperature in Celsius
)
TEMP_CURRENT_EXTERNAL_1 = (
"temp_current_external_1" # Current external temperature in Celsius
)
TEMP_CURRENT_EXTERNAL_2 = (
"temp_current_external_2" # Current external temperature in Celsius
)
TEMP_CURRENT_EXTERNAL_3 = (
"temp_current_external_3" # Current external temperature in Celsius
)
TEMP_CURRENT_EXTERNAL_F = (
"temp_current_external_f" # Current external temperature in Fahrenheit
)
@@ -405,6 +419,7 @@ class DPCode(StrEnum):
WINDOW_CHECK = "window_check"
WINDOW_STATE = "window_state"
WINDSPEED = "windspeed"
WINDSPEED_AVG = "windspeed_avg"
WIRELESS_BATTERYLOCK = "wireless_batterylock"
WIRELESS_ELECTRICITY = "wireless_electricity"
WORK_MODE = "work_mode" # Working mode

View File

@@ -267,7 +267,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
return int(self._speed.remap_value_to(value, 1, 100))
if self._speeds is not None:
if (value := self.device.status.get(self._speeds.dpcode)) is None:
if (
value := self.device.status.get(self._speeds.dpcode)
) is None or value not in self._speeds.range:
return None
return ordered_list_item_to_percentage(self._speeds.range, value)

View File

@@ -846,6 +846,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT_EXTERNAL_1,
translation_key="indexed_temperature_external",
translation_placeholders={"index": "1"},
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT_EXTERNAL_2,
translation_key="indexed_temperature_external",
translation_placeholders={"index": "2"},
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT_EXTERNAL_3,
translation_key="indexed_temperature_external",
translation_placeholders={"index": "3"},
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.VA_HUMIDITY,
translation_key="humidity",
@@ -858,12 +879,51 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_OUTDOOR,
translation_key="humidity_outdoor",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_OUTDOOR_1,
translation_key="indexed_humidity_outdoor",
translation_placeholders={"index": "1"},
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_OUTDOOR_2,
translation_key="indexed_humidity_outdoor",
translation_placeholders={"index": "2"},
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_OUTDOOR_3,
translation_key="indexed_humidity_outdoor",
translation_placeholders={"index": "3"},
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.ATMOSPHERIC_PRESSTURE,
translation_key="air_pressure",
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.BRIGHT_VALUE,
translation_key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.WINDSPEED_AVG,
translation_key="wind_speed",
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
*BATTERY_SENSORS,
),
# Gas Detector

View File

@@ -502,9 +502,21 @@
"temperature_external": {
"name": "Probe temperature"
},
"indexed_temperature_external": {
"name": "Probe temperature channel {index}"
},
"humidity": {
"name": "[%key:component::sensor::entity_component::humidity::name%]"
},
"humidity_outdoor": {
"name": "Outdoor humidity"
},
"indexed_humidity_outdoor": {
"name": "Outdoor humidity channel {index}"
},
"air_pressure": {
"name": "Air pressure"
},
"pm25": {
"name": "[%key:component::sensor::entity_component::pm25::name%]"
},

View File

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

View File

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

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:
# Create a controller status sensor for each device
@@ -637,8 +637,8 @@ class ControllerEvents:
f"{DOMAIN}.identify_controller.{dev_id[1]}",
)
async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None:
"""Check if the node was preprovisioned and update the device registry."""
async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None:
"""Check if the node was pre-provisioned and update the device registry."""
provisioning_entry = (
await self.driver_events.driver.controller.async_get_provisioning_entry(
node.node_id
@@ -648,29 +648,37 @@ class ControllerEvents:
provisioning_entry
and provisioning_entry.additional_properties
and "device_id" in provisioning_entry.additional_properties
):
preprovisioned_device = self.dev_reg.async_get(
provisioning_entry.additional_properties["device_id"]
and (
pre_provisioned_device := self.dev_reg.async_get(
provisioning_entry.additional_properties["device_id"]
)
)
and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}"))
in pre_provisioned_device.identifiers
):
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
new_identifiers = pre_provisioned_device.identifiers.copy()
new_identifiers.remove(dsk_identifier)
new_identifiers.add(device_id)
if device_id_ext:
new_identifiers.add(device_id_ext)
if preprovisioned_device:
dsk = provisioning_entry.dsk
dsk_identifier = (DOMAIN, f"provision_{dsk}")
# If the pre-provisioned device has the DSK identifier, remove it
if dsk_identifier in preprovisioned_device.identifiers:
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
new_identifiers = preprovisioned_device.identifiers.copy()
new_identifiers.remove(dsk_identifier)
new_identifiers.add(device_id)
if device_id_ext:
new_identifiers.add(device_id_ext)
self.dev_reg.async_update_device(
preprovisioned_device.id,
new_identifiers=new_identifiers,
)
if self.dev_reg.async_get_device(identifiers=new_identifiers):
# If a device entry is registered with the node ID based identifiers,
# just remove the device entry with the DSK identifier.
self.dev_reg.async_update_device(
pre_provisioned_device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
else:
# Add the node ID based identifiers to the device entry
# with the DSK identifier and remove the DSK identifier.
self.dev_reg.async_update_device(
pre_provisioned_device.id,
new_identifiers=new_identifiers,
)
async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
"""Register node in dev reg."""

View File

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

View File

@@ -34,8 +34,11 @@ from ..helpers import (
)
from .trigger_helpers import async_bypass_dynamic_config_validation
# Relative platform type should be <SUBMODULE_NAME>
RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}"
# Platform type should be <DOMAIN>.<SUBMODULE_NAME>
PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}"
PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}"
def validate_non_node_event_source(obj: dict) -> dict:

View File

@@ -37,8 +37,11 @@ from ..const import (
from ..helpers import async_get_nodes_from_targets, get_device_id
from .trigger_helpers import async_bypass_dynamic_config_validation
# Relative platform type should be <SUBMODULE_NAME>
RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}"
# Platform type should be <DOMAIN>.<SUBMODULE_NAME>
PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}"
PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}"
ATTR_FROM = "from"
ATTR_TO = "to"

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})")
def underscore_slug(value: Any) -> str:
"""Validate value is a valid slug, possibly starting with an underscore."""
if value.startswith("_"):
return f"_{slug(value[1:])}"
return slug(value)
def schema_with_slug_keys(
value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug
) -> Callable:

View File

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

View File

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

View File

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

2
requirements.txt generated
View File

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

16
requirements_all.txt generated
View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -2,7 +2,7 @@
import asyncio
from typing import Any
from unittest.mock import patch
from unittest.mock import PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away(
assert hass_storage[auth_store.STORAGE_KEY] != {}
async def test_duplicate_uuid(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test we don't override user if we have a duplicate user ID."""
hass_storage[auth_store.STORAGE_KEY] = MOCK_STORAGE_DATA
store = auth_store.AuthStore(hass)
await store.async_load()
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock:
hex_mock.side_effect = ["user-id", "new-id"]
user = await store.async_create_user("Test User")
assert len(hex_mock.mock_calls) == 2
assert user.id == "new-id"
async def test_add_remove_user_affects_tokens(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:

View File

@@ -439,64 +439,6 @@
'state': '5500',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'ap_ptp',
'sta_ptp',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Wireless mode',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wireless_mode',
'unique_id': '01:23:45:67:89:AB_wireless_mode',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'NanoStation 5AC ap name Wireless mode',
'options': list([
'ap_ptp',
'sta_ptp',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'ap_ptp',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

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

View File

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

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."""
from unittest.mock import patch
from pathlib import Path
import pytest
from homeassistant.components.downloader.const import (
CONF_DOWNLOAD_DIR,
@@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_initialization(hass: HomeAssistant) -> None:
"""Test the initialization of the downloader component."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_DOWNLOAD_DIR: "/test_dir",
},
)
config_entry.add_to_hass(hass)
with patch("os.path.isdir", return_value=True):
assert await hass.config_entries.async_setup(config_entry.entry_id)
@pytest.fixture
def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
"""Return a download directory."""
if hasattr(request, "param"):
return tmp_path / request.param
return tmp_path
async def test_config_entry_setup(
hass: HomeAssistant, setup_integration: MockConfigEntry
) -> None:
"""Test config entry setup."""
config_entry = setup_integration
assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE)
assert config_entry.state is ConfigEntryState.LOADED
async def test_config_entry_setup_relative_directory(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test config entry setup with a relative download directory."""
relative_directory = "downloads"
hass.config_entries.async_update_entry(
mock_config_entry,
data={**mock_config_entry.data, CONF_DOWNLOAD_DIR: relative_directory},
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
# The config entry will fail to set up since the directory does not exist.
# This is not relevant for this test.
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert mock_config_entry.data[CONF_DOWNLOAD_DIR] == hass.config.path(
relative_directory
)
@pytest.mark.parametrize(
"download_dir",
[
"not_existing_path",
],
indirect=True,
)
async def test_config_entry_setup_not_existing_directory(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config entry setup without existing download directory."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert not hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

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

@@ -400,10 +400,10 @@ async def test_options_flow_preview(
msg = await client.receive_json()
assert msg["event"]["state"] == exp_count
hass.states.async_set(monitored_entity, "on")
hass.states.async_set(monitored_entity, "on")
msg = await client.receive_json()
assert msg["event"]["state"] == "3"
msg = await client.receive_json()
assert msg["event"]["state"] == "3"
async def test_options_flow_preview_errors(

View File

@@ -47,6 +47,54 @@
'state': 'unavailable',
})
# ---
# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Reset cutting blade usage time',
'platform': 'husqvarna_automower',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'reset_cutting_blade_usage_time',
'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_reset_cutting_blade_usage_time',
'unit_of_measurement': None,
})
# ---
# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Mower 1 Reset cutting blade usage time',
}),
'context': <ANY>,
'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button_snapshot[button.test_mower_1_sync_clock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

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))
async def test_button_states_and_commands(
async def test_button_error_confirm(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
@@ -58,42 +58,43 @@ async def test_button_states_and_commands(
state = hass.states.get(entity_id)
assert state.state == STATE_UNKNOWN
await hass.services.async_call(
domain="button",
service=SERVICE_PRESS,
target={ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == "2023-06-05T00:16:00+00:00"
mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error")
with pytest.raises(
HomeAssistantError,
match="Failed to send command: Test error",
):
await hass.services.async_call(
domain="button",
service=SERVICE_PRESS,
target={ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.parametrize(
("entity_id", "name", "expected_command"),
[
(
"button.test_mower_1_confirm_error",
"Test Mower 1 Confirm error",
"error_confirm",
),
(
"button.test_mower_1_sync_clock",
"Test Mower 1 Sync clock",
"set_datetime",
),
(
"button.test_mower_1_reset_cutting_blade_usage_time",
"Test Mower 1 Reset cutting blade usage time",
"reset_cutting_blade_usage_time",
),
],
)
@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC))
async def test_sync_clock(
async def test_button_commands(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
values: dict[str, MowerAttributes],
entity_id: str,
name: str,
expected_command: str,
) -> None:
"""Test sync clock button command."""
entity_id = "button.test_mower_1_sync_clock"
"""Test Automower button commands."""
values[TEST_MOWER_ID].mower.is_error_confirmable = True
await setup_integration(hass, mock_config_entry)
state = hass.states.get(entity_id)
assert state.name == "Test Mower 1 Sync clock"
assert state.name == name
mock_automower_client.get_status.return_value = values
@@ -103,11 +104,15 @@ async def test_sync_clock(
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID)
command_mock = getattr(mock_automower_client.commands, expected_command)
command_mock.assert_called_once_with(TEST_MOWER_ID)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == "2024-02-29T11:00:00+00:00"
mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error")
command_mock.reset_mock()
command_mock.side_effect = ApiError("Test error")
with pytest.raises(
HomeAssistantError,
match="Failed to send command: Test error",

View File

@@ -14,7 +14,7 @@ from aioautomower.exceptions import (
HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError,
)
from aioautomower.model import Calendar, MowerAttributes, WorkArea
from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -484,3 +484,212 @@ async def test_add_and_remove_work_area(
- ADDITIONAL_NUMBER_ENTITIES
- ADDITIONAL_SENSOR_ENTITIES
)
@pytest.mark.parametrize(
("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"),
[
(True, MowerStates.OFF, False, MowerStates.OFF), # False
(False, MowerStates.PAUSED, False, MowerStates.OFF), # False
(False, MowerStates.OFF, True, MowerStates.OFF), # False
(False, MowerStates.OFF, False, MowerStates.PAUSED), # False
(True, MowerStates.OFF, True, MowerStates.OFF), # False
(False, MowerStates.OFF, False, MowerStates.OFF), # False
],
)
async def test_dynamic_polling(
hass: HomeAssistant,
mock_automower_client,
mock_config_entry,
freezer: FrozenDateTimeFactory,
values: dict[str, MowerAttributes],
mower1_connected: bool,
mower1_state: MowerStates,
mower2_connected: bool,
mower2_state: MowerStates,
) -> None:
"""Test that the ws_ready_callback triggers an attempt to start the Watchdog task.
and that the pong callback stops polling when all mowers are inactive.
"""
websocket_values = deepcopy(values)
poll_values = deepcopy(values)
callback_holder: dict[str, Callable] = {}
@callback
def fake_register_websocket_response(
cb: Callable[[dict[str, MowerAttributes]], None],
) -> None:
callback_holder["data_cb"] = cb
mock_automower_client.register_data_callback.side_effect = (
fake_register_websocket_response
)
@callback
def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None:
callback_holder["ws_ready_cb"] = cb
mock_automower_client.register_ws_ready_callback.side_effect = (
fake_register_ws_ready_callback
)
await setup_integration(hass, mock_config_entry)
assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered"
callback_holder["ws_ready_cb"]()
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 2
# websocket is still active, but mowers are inactive -> no polling required
poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected
poll_values[TEST_MOWER_ID].mower.state = mower1_state
poll_values["1234"].metadata.connected = mower2_connected
poll_values["1234"].mower.state = mower2_state
mock_automower_client.get_status.return_value = poll_values
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 3
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 4
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 4
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 4
# websocket is still active, and mowers are active -> polling required
mock_automower_client.get_status.reset_mock()
assert mock_automower_client.get_status.call_count == 0
poll_values[TEST_MOWER_ID].metadata.connected = True
poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED
poll_values["1234"].metadata.connected = False
poll_values["1234"].mower.state = MowerStates.OFF
websocket_values = deepcopy(poll_values)
callback_holder["data_cb"](websocket_values)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 2
@pytest.mark.parametrize(
("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"),
[
(True, MowerStates.OFF, False, MowerStates.OFF), # False
(False, MowerStates.PAUSED, False, MowerStates.OFF), # False
(False, MowerStates.OFF, True, MowerStates.OFF), # False
(False, MowerStates.OFF, False, MowerStates.PAUSED), # False
(True, MowerStates.OFF, True, MowerStates.OFF), # False
(False, MowerStates.OFF, False, MowerStates.OFF), # False
],
)
async def test_websocket_watchdog(
hass: HomeAssistant,
mock_automower_client,
mock_config_entry,
freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
values: dict[str, MowerAttributes],
mower1_connected: bool,
mower1_state: MowerStates,
mower2_connected: bool,
mower2_state: MowerStates,
) -> None:
"""Test that the ws_ready_callback triggers an attempt to start the Watchdog task.
and that the pong callback stops polling when all mowers are inactive.
"""
poll_values = deepcopy(values)
callback_holder: dict[str, Callable] = {}
@callback
def fake_register_websocket_response(
cb: Callable[[dict[str, MowerAttributes]], None],
) -> None:
callback_holder["data_cb"] = cb
mock_automower_client.register_data_callback.side_effect = (
fake_register_websocket_response
)
@callback
def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None:
callback_holder["ws_ready_cb"] = cb
mock_automower_client.register_ws_ready_callback.side_effect = (
fake_register_ws_ready_callback
)
await setup_integration(hass, mock_config_entry)
assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered"
callback_holder["ws_ready_cb"]()
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 2
# websocket is still active, but mowers are inactive -> no polling required
poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected
poll_values[TEST_MOWER_ID].mower.state = mower1_state
poll_values["1234"].metadata.connected = mower2_connected
poll_values["1234"].mower.state = mower2_state
mock_automower_client.get_status.return_value = poll_values
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 3
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 4
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 4
# Simulate Pong loss and reset mock -> polling required
mock_automower_client.send_empty_message.return_value = False
mock_automower_client.get_status.reset_mock()
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 0
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 2

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Generator
from collections.abc import AsyncGenerator, Generator
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -130,6 +130,28 @@ def mock_smile_config_flow() -> Generator[MagicMock]:
yield api
@pytest.fixture
def platforms() -> list[str]:
"""Fixture for platforms."""
return []
@pytest.fixture
async def setup_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
platforms,
) -> AsyncGenerator[None]:
"""Set up one or all platforms."""
mock_config_entry.add_to_hass(hass)
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
yield mock_config_entry
@pytest.fixture
def mock_smile_adam() -> Generator[MagicMock]:
"""Create a Mock Adam environment for testing exceptions."""

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 homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
SERVICE_PRESS,
ButtonDeviceClass,
)
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, snapshot_platform
async def test_adam_reboot_button(
@pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_adam_button_snapshot(
hass: HomeAssistant,
mock_smile_adam: MagicMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: MockConfigEntry,
) -> None:
"""Test Adam button snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
async def test_adam_press_reboot_button(
hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry
) -> None:
"""Test creation of button entities."""
state = hass.states.get("button.adam_reboot")
assert state
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART
registry = er.async_get(hass)
entry = registry.async_get("button.adam_reboot")
assert entry
assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot"
"""Test pressing of button entity."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,

View File

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

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.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component
@@ -589,6 +589,40 @@ async def test_battery_level_template(
_verify(hass, STATE_UNKNOWN, expected)
@pytest.mark.parametrize(
("count", "state_template", "extra_config", "attribute_template"),
[(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")],
)
@pytest.mark.parametrize(
("style", "attribute"),
[
(ConfigurationStyle.LEGACY, "battery_level_template"),
(ConfigurationStyle.MODERN, "battery_level"),
(ConfigurationStyle.TRIGGER, "battery_level"),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_vacuum")
async def test_battery_level_template_repair(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test battery_level template raises issue."""
# Ensure trigger entity templates are rendered
hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED)
await hass.async_block_till_done()
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(
"template", f"deprecated_battery_level_{TEST_ENTITY_ID}"
)
assert issue.domain == "template"
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID
assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID
assert "Detected that integration 'template' is setting the" not in caplog.text
@pytest.mark.parametrize(
("count", "state_template", "extra_config"),
[

View File

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

View File

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

View File

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

View File

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

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",
"terminal_id": "mock_terminal_id",
"terminal_id": "REDACTED",
"mqtt_connected": true,
"disabled_by": null,
"disabled_polling": false,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "1733006572651YokbqV",
"terminal_id": "REDACTED",
"mqtt_connected": null,
"disabled_by": null,
"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",
"terminal_id": "1732306182276g6jQLp",
"terminal_id": "REDACTED",
"mqtt_connected": true,
"disabled_by": null,
"disabled_polling": false,
"id": "eb3e988f33c233290cfs3l",
"name": "Colorful PIR Night Light",
"category": "gyd",

View File

@@ -1,6 +1,6 @@
{
"endpoint": "https://apigw.tuyaus.com",
"terminal_id": "1750526976566fMhqJs",
"terminal_id": "REDACTED",
"mqtt_connected": true,
"disabled_by": null,
"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",
"terminal_id": "CENSORED",
"terminal_id": "REDACTED",
"mqtt_connected": true,
"disabled_by": null,
"disabled_polling": false,

View File

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

View File

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

View File

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

View File

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

View File

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

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