Compare commits

..

14 Commits

Author SHA1 Message Date
Ludovic BOUÉ
725bd3d671 Add mock thermostat entity and state snapshots for temperature display mode 2025-11-21 12:38:04 +00:00
Ludovic BOUÉ
cfc4fa6342 Merge branch 'dev' into setpoint_change_source 2025-11-21 13:35:31 +01:00
Ludovic BOUÉ
b650e71660 Update mock thermostat snapshots with new attributes and state values 2025-11-18 18:05:29 +00:00
Ludovic BOUÉ
9ddf15e348 Update mock thermostat JSON with additional attributes and values 2025-11-18 18:03:46 +00:00
Ludovic BOUÉ
15082f9111 Merge branch 'dev' into setpoint_change_source 2025-11-18 16:45:05 +01:00
Ludovic BOUÉ
12f16611ff Rename mock thermostat entity IDs and friendly names in snapshots for consistency 2025-11-18 15:30:39 +00:00
Ludovic BOUÉ
8041be3d08 Merge branch 'dev' into setpoint_change_source 2025-11-18 14:08:38 +01:00
Ludovic BOUÉ
40b021e755 Add tests for Thermostat SetpointChangeSource, Timestamp, and Amount sensors 2025-11-18 13:02:44 +00:00
Ludovic BOUÉ
aab57eda96 Update mock thermostat product name to "Mock Thermostat" 2025-11-18 13:00:26 +00:00
Ludovic BOUÉ
f0dd37caa5 Add mock thermostat sensors and states for testing 2025-11-18 12:49:32 +00:00
Ludovic BOUÉ
662b178495 Remove unused attribute from thermostat fixture 2025-11-18 12:48:56 +00:00
Ludovic BOUÉ
cb3d30884a Add mock thermostat fixture for integration tests 2025-11-18 12:48:21 +00:00
Ludovic BOUÉ
49e6f20372 Add Setpoint Change Source timestamp and amount sensors with localization strings 2025-11-18 12:39:28 +00:00
Ludovic BOUÉ
75d02661eb Add Setpoint Change Source sensor and localization strings 2025-11-14 17:19:28 +00:00
76 changed files with 1919 additions and 7143 deletions

View File

@@ -88,6 +88,10 @@ jobs:
fail-fast: false
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
exclude:
- arch: armv7
- arch: armhf
- arch: i386
steps:
- name: Checkout the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0

View File

@@ -77,8 +77,20 @@ jobs:
# Use C-Extension for SQLAlchemy
echo "REQUIRE_SQLALCHEMY_CEXT=1"
# Add additional pip wheel build constraints
echo "PIP_CONSTRAINT=build_constraints.txt"
) > .env_file
- name: Write pip wheel build constraints
run: |
(
# ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
# this caused the numpy builds to fail
# https://github.com/scikit-build/ninja-python-distributions/issues/274
echo "ninja==1.11.1.1"
) > build_constraints.txt
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
@@ -87,6 +99,13 @@ jobs:
include-hidden-files: true
overwrite: true
- name: Upload build_constraints
uses: *actions-upload-artifact
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff
uses: *actions-upload-artifact
with:
@@ -119,6 +138,13 @@ jobs:
- os: ubuntu-latest
- arch: aarch64
os: ubuntu-24.04-arm
exclude:
- abi: cp314
arch: armv7
- abi: cp314
arch: armhf
- abi: cp314
arch: i386
steps:
- *checkout
@@ -128,6 +154,12 @@ jobs:
with:
name: env_file
- &download-build-constraints
name: Download build_constraints
uses: *actions-download-artifact
with:
name: build_constraints
- &download-requirements-diff
name: Download requirements_diff
uses: *actions-download-artifact
@@ -167,7 +199,7 @@ jobs:
- *checkout
- *download-env-file
- *download-build-constraints
- *download-requirements-diff
- name: Download requirements_all_wheels
@@ -177,6 +209,10 @@ jobs:
- name: Adjust build env
run: |
if [ "${{ matrix.arch }}" = "i386" ]; then
echo "NPY_DISABLE_SVML=1" >> .env_file
fi
# Do not pin numpy in wheels building
sed -i "/numpy/d" homeassistant/package_constraints.txt
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine

2
Dockerfile generated
View File

@@ -21,6 +21,8 @@ ARG BUILD_ARCH
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \

View File

@@ -1,7 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/awair",
"iot_class": "local_polling",
"loggers": ["python_awair"],
"requirements": ["python-awair==0.2.5"],
"requirements": ["python-awair==0.2.4"],
"zeroconf": [
{
"name": "awair*",

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
import logging
from typing import Any, cast
from typing import cast
from hass_nabucasa import Cloud
import voluptuous as vol
@@ -86,10 +86,6 @@ SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType(
"CLOUD_CONNECTION_STATE"
)
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
"CLOUDHOOKS_UPDATED"
)
STARTUP_REPAIR_DELAY = 1 # 1 hour
ALEXA_ENTITY_SCHEMA = vol.Schema(
@@ -246,24 +242,6 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
@callback
def async_listen_cloudhook_change(
hass: HomeAssistant,
webhook_id: str,
on_change: Callable[[dict[str, Any] | None], None],
) -> Callable[[], None]:
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
@callback
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
"""Handle cloudhooks updated signal."""
on_change(cloudhooks.get(webhook_id))
return async_dispatcher_connect(
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
)
@bind_hass
@callback
def async_remote_ui_url(hass: HomeAssistant) -> str:
@@ -311,7 +289,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
_handle_prefs_updated(hass, cloud)
_remote_handle_prefs_updated(cloud)
_setup_services(hass, prefs)
async def async_startup_repairs(_: datetime) -> None:
@@ -395,32 +373,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
"""Register handler for cloud preferences updates."""
cur_remote_enabled = cloud.client.prefs.remote_enabled
cur_cloudhooks = cloud.client.prefs.cloudhooks
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
"""Handle remote preferences updated."""
cur_pref = cloud.client.prefs.remote_enabled
lock = asyncio.Lock()
async def on_prefs_updated(prefs: CloudPreferences) -> None:
"""Handle cloud preferences updates."""
nonlocal cur_remote_enabled
nonlocal cur_cloudhooks
# Sync remote connection with prefs
async def remote_prefs_updated(prefs: CloudPreferences) -> None:
"""Update remote status."""
nonlocal cur_pref
# Lock protects cur_ state variables from concurrent updates
async with lock:
if cur_cloudhooks != prefs.cloudhooks:
cur_cloudhooks = prefs.cloudhooks
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
if prefs.remote_enabled == cur_remote_enabled:
if prefs.remote_enabled == cur_pref:
return
if cur_remote_enabled := prefs.remote_enabled:
if cur_pref := prefs.remote_enabled:
await cloud.remote.connect()
else:
await cloud.remote.disconnect()
cloud.client.prefs.async_listen_updates(on_prefs_updated)
cloud.client.prefs.async_listen_updates(remote_prefs_updated)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
@@ -26,7 +25,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
@@ -169,7 +167,6 @@ class DecoraWifiLight(LightEntity):
except ValueError:
_LOGGER.error("Failed to turn off myLeviton switch")
@Throttle(timedelta(seconds=30))
def update(self) -> None:
"""Fetch new state data for this switch."""
try:

View File

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

View File

@@ -2,11 +2,10 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
import shutil
from aiohttp import ClientSession, UnixConnector
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
@@ -53,7 +52,6 @@ from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_UNIX_SOCKET,
HA_MANAGED_URL,
RECOMMENDED_VERSION,
)
@@ -75,7 +73,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
_DATA_GO2RTC: HassKey[Go2RtcConfig] = HassKey(DOMAIN)
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
@@ -102,12 +100,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return False
# HA will manage the binary
session = ClientSession(connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET))
server = Server(
hass,
binary,
session,
enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False),
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
)
try:
await server.start()
@@ -117,15 +111,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def on_stop(event: Event) -> None:
await server.stop()
await session.close()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL
else:
session = async_get_clientsession(hass)
hass.data[_DATA_GO2RTC] = Go2RtcConfig(url, session)
hass.data[_DATA_GO2RTC] = url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
@@ -141,9 +132,8 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
"""Set up go2rtc from a config entry."""
config = hass.data[_DATA_GO2RTC]
url = config.url
session = config.session
url = hass.data[_DATA_GO2RTC]
session = async_get_clientsession(hass)
client = Go2RtcRestClient(session, url)
# Validate the server URL
try:
@@ -352,11 +342,3 @@ class WebRTCProvider(CameraWebRTCProvider):
for ws_client in self._sessions.values():
await ws_client.close()
self._sessions.clear()
@dataclass
class Go2RtcConfig:
"""Go2rtc configuration."""
url: str
session: ClientSession

View File

@@ -6,5 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
RECOMMENDED_VERSION = "1.9.12"

View File

@@ -6,13 +6,13 @@ from contextlib import suppress
import logging
from tempfile import NamedTemporaryFile
from aiohttp import ClientSession
from go2rtc_client import Go2RtcRestClient
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
@@ -23,8 +23,7 @@ _LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1
# Default configuration for HA
# - Unix socket for secure local communication
# - HTTP API only enabled when UI is enabled
# - Api is listening only on localhost
# - Enable rtsp for localhost only as ffmpeg needs it
# - Clear default ice servers
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
@@ -34,8 +33,7 @@ app:
modules: {app_modules}
api:
listen: "{listen_config}"
unix_listen: "{unix_socket}"
listen: "{api_ip}:{api_port}"
allow_paths: {api_allow_paths}
# ffmpeg needs the exec module
@@ -122,24 +120,20 @@ def _create_temp_file(enable_ui: bool) -> str:
"""Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
api_ip = _LOCALHOST_IP
if enable_ui:
app_modules = _UI_APP_MODULES
api_paths = _UI_API_ALLOW_PATHS
# Listen on all interfaces for allowing access from all ips
listen_config = f":{HA_MANAGED_API_PORT}"
else:
# Disable HTTP listening when UI is not enabled
# as HA does not use it.
listen_config = ""
api_ip = ""
# Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write(
_GO2RTC_CONFIG_FORMAT.format(
listen_config=listen_config,
unix_socket=HA_MANAGED_UNIX_SOCKET,
api_ip=api_ip,
api_port=HA_MANAGED_API_PORT,
app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths),
).encode()
@@ -151,17 +145,11 @@ class Server:
"""Go2rtc server."""
def __init__(
self,
hass: HomeAssistant,
binary: str,
session: ClientSession,
*,
enable_ui: bool = False,
self, hass: HomeAssistant, binary: str, *, enable_ui: bool = False
) -> None:
"""Initialize the server."""
self._hass = hass
self._binary = binary
self._session = session
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
@@ -209,7 +197,7 @@ class Server:
raise Go2RTCServerStartError from err
# Check the server version
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
await client.validate_server_version()
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
@@ -281,7 +269,7 @@ class Server:
async def _monitor_api(self) -> None:
"""Raise if the go2rtc process terminates."""
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
_LOGGER.debug("Monitoring go2rtc API")
try:

View File

@@ -145,10 +145,10 @@
"loop": "Loop",
"off": "[%key:common::state::off%]",
"seconds_1": "1 second",
"seconds_2": "2 seconds",
"seconds_3": "3 seconds",
"seconds_4": "4 seconds",
"seconds_5": "5 seconds"
"seconds_2": "2 second",
"seconds_3": "3 second",
"seconds_4": "4 second",
"seconds_5": "5 second"
}
},
"min_dc_voltage_cells": {

View File

@@ -15,20 +15,16 @@ from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.util import InstallationKey, generate_installation_key
from homeassistant.components.bluetooth import (
async_ble_device_from_address,
async_discovered_service_info,
)
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.const import (
CONF_MAC,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
__version__,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
@@ -103,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
# initialize Bluetooth
bluetooth_client: LaMarzoccoBluetoothClient | None = None
if entry.options.get(CONF_USE_BLUETOOTH, True) and (
token := (entry.data.get(CONF_TOKEN) or settings.ble_auth_token)
token := settings.ble_auth_token
):
if CONF_MAC not in entry.data:
for discovery_info in async_discovered_service_info(hass):
@@ -112,7 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
and name.startswith(BT_MODEL_PREFIXES)
and name.split("_")[1] == serial
):
_LOGGER.info("Found lamarzocco Bluetooth device, adding to entry")
_LOGGER.debug("Found Bluetooth device, configuring with Bluetooth")
# found a device, add MAC address to config entry
hass.config_entries.async_update_entry(
entry,
@@ -122,30 +118,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
},
)
if not entry.data[CONF_TOKEN]:
# update the token in the config entry
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: token,
},
)
if CONF_MAC in entry.data:
ble_device = async_ble_device_from_address(hass, entry.data[CONF_MAC])
if ble_device:
_LOGGER.info("Setting up lamarzocco with Bluetooth")
_LOGGER.debug("Initializing Bluetooth device")
bluetooth_client = LaMarzoccoBluetoothClient(
ble_device=ble_device,
address_or_ble_device=entry.data[CONF_MAC],
ble_token=token,
)
async def disconnect_bluetooth(_: Event) -> None:
"""Stop push updates when hass stops."""
await bluetooth_client.disconnect()
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, disconnect_bluetooth
)
)
entry.async_on_unload(bluetooth_client.disconnect)
else:
_LOGGER.info(
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
)
device = LaMarzoccoMachine(
serial_number=entry.unique_id,
cloud_client=cloud_client,

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.2.0"]
"requirements": ["pylamarzocco==2.1.3"]
}

View File

@@ -183,6 +183,13 @@ PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
}
SETPOINT_CHANGE_SOURCE_MAP = {
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
}
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
@@ -1488,4 +1495,47 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSource",
translation_key="setpoint_change_source",
device_class=SensorDeviceClass.ENUM,
state_class=None,
# convert to set first to remove the duplicate unknown value
options=[x for x in SETPOINT_CHANGE_SOURCE_MAP.values() if x is not None],
device_to_ha=lambda x: SETPOINT_CHANGE_SOURCE_MAP[x],
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSourceTimestamp",
translation_key="setpoint_change_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None,
device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
),
entity_class=MatterSensor,
required_attributes=(
clusters.Thermostat.Attributes.SetpointChangeSourceTimestamp,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatSetpointChangeAmount",
translation_key="setpoint_change_amount",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeAmount,),
device_type=(device_types.Thermostat,),
),
]

View File

@@ -223,6 +223,9 @@
"pump_setpoint": {
"name": "Setpoint"
},
"setpoint_change_source_timestamp": {
"name": "Last change"
},
"temperature_offset": {
"name": "Temperature offset"
},
@@ -518,6 +521,20 @@
"rms_voltage": {
"name": "Effective voltage"
},
"setpoint_change_amount": {
"name": "Last change amount"
},
"setpoint_change_source": {
"name": "Last change source",
"state": {
"external": "External",
"manual": "Manual",
"schedule": "Schedule"
}
},
"setpoint_change_timestamp": {
"name": "Last change"
},
"switch_current_position": {
"name": "Current switch position"
},

View File

@@ -131,41 +131,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
def clean_cloudhook() -> None:
"""Clean up cloudhook from config entry."""
if CONF_CLOUDHOOK_URL in entry.data:
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook changes."""
if cloudhook:
if entry.data.get(CONF_CLOUDHOOK_URL) == cloudhook[CONF_CLOUDHOOK_URL]:
return
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]},
)
else:
clean_cloudhook()
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if (
state is cloud.CloudConnectionState.CLOUD_CONNECTED
and CONF_CLOUDHOOK_URL not in entry.data
):
await async_create_cloud_hook(hass, webhook_id, entry)
elif (
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
and not cloud.async_is_logged_in(hass)
):
clean_cloudhook()
entry.async_on_unload(
cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change)
)
if cloud.async_is_logged_in(hass):
if (
@@ -176,7 +147,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await async_create_cloud_hook(hass, webhook_id, entry)
elif CONF_CLOUDHOOK_URL in entry.data:
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
clean_cloudhook()
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))

View File

@@ -756,9 +756,10 @@ async def webhook_get_config(
"theme_color": MANIFEST_JSON["theme_color"],
}
if cloud.async_active_subscription(hass):
if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
if cloud.async_active_subscription(hass):
with suppress(cloud.CloudNotAvailable):
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)

View File

@@ -68,6 +68,7 @@ async def websocket_network_adapters_configure(
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "network/url",

View File

@@ -28,13 +28,9 @@ async def test_connection(host: str) -> str | None:
controller = NHCController(host, 8000)
try:
await controller.connect()
except TimeoutError:
return "timeout_connect"
except OSError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception during connection")
return "unknown"
_LOGGER.exception("Unexpected exception")
return "cannot_connect"
return None

View File

@@ -5,9 +5,7 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"reconfigure": {

View File

@@ -129,9 +129,6 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.REMOTE_CONTROL_ENABLED,
translation_key="remote_control",
is_on_key="true",
component_translation_key={
"sub": "sub_remote_control",
},
)
},
Capability.SOUND_SENSOR: {

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.4"]
"requirements": ["pysmartthings==3.3.3"]
}

View File

@@ -98,7 +98,6 @@ class SmartThingsSelectDescription(SelectEntityDescription):
default_options: list[str] | None = None
extra_components: list[str] | None = None
capability_ignore_list: list[Capability] | None = None
value_is_integer: bool = False
CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = {
@@ -186,15 +185,6 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = {
options_map=WASHER_WATER_TEMPERATURE_TO_HA,
entity_category=EntityCategory.CONFIG,
),
Capability.SAMSUNG_CE_DUST_FILTER_ALARM: SmartThingsSelectDescription(
key=Capability.SAMSUNG_CE_DUST_FILTER_ALARM,
translation_key="dust_filter_alarm",
options_attribute=Attribute.SUPPORTED_ALARM_THRESHOLDS,
status_attribute=Attribute.ALARM_THRESHOLD,
command=Command.SET_ALARM_THRESHOLD,
entity_category=EntityCategory.CONFIG,
value_is_integer=True,
),
}
@@ -263,8 +253,6 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity):
self.entity_description.options_map.get(option, option)
for option in options
]
if self.entity_description.value_is_integer:
options = [str(option) for option in options]
return options
@property
@@ -275,8 +263,6 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity):
)
if self.entity_description.options_map:
option = self.entity_description.options_map.get(option)
if self.entity_description.value_is_integer and option is not None:
option = str(option)
return option
async def async_select_option(self, option: str) -> None:
@@ -291,20 +277,17 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity):
raise ServiceValidationError(
"Can only be updated when remote control is enabled"
)
new_option: str | int = option
if self.entity_description.options_map:
new_option = next(
option = next(
(
key
for key, value in self.entity_description.options_map.items()
if value == option
),
new_option,
option,
)
if self.entity_description.value_is_integer:
new_option = int(option)
await self.execute_device_command(
self.entity_description.key,
self.entity_description.command,
new_option,
option,
)

View File

@@ -1054,10 +1054,6 @@ CAPABILITY_TO_SENSORS: dict[
translation_key="washer_machine_state",
options=WASHER_OPTIONS,
device_class=SensorDeviceClass.ENUM,
component_fn=lambda component: component == "sub",
component_translation_key={
"sub": "washer_sub_machine_state",
},
)
],
Attribute.WASHER_JOB_STATE: [
@@ -1084,10 +1080,6 @@ CAPABILITY_TO_SENSORS: dict[
],
device_class=SensorDeviceClass.ENUM,
value_fn=lambda value: JOB_STATE_MAP.get(value, value),
component_fn=lambda component: component == "sub",
component_translation_key={
"sub": "washer_sub_job_state",
},
)
],
Attribute.COMPLETION_TIME: [
@@ -1096,10 +1088,6 @@ CAPABILITY_TO_SENSORS: dict[
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
component_fn=lambda component: component == "sub",
component_translation_key={
"sub": "washer_sub_completion_time",
},
)
],
},

View File

@@ -66,9 +66,6 @@
"remote_control": {
"name": "Remote control"
},
"sub_remote_control": {
"name": "Upper washer remote control"
},
"valve": {
"name": "Valve"
}
@@ -164,10 +161,6 @@
"standard": "Standard"
}
},
"dust_filter_alarm": {
"name": "Dust filter alarm threshold",
"unit_of_measurement": "hours"
},
"flexible_detergent_amount": {
"name": "Flexible compartment dispense amount",
"state": {
@@ -643,38 +636,6 @@
"washer_mode": {
"name": "Washer mode"
},
"washer_sub_completion_time": {
"name": "Upper washer completion time"
},
"washer_sub_job_state": {
"name": "Upper washer job state",
"state": {
"ai_rinse": "[%key:component::smartthings::entity::sensor::washer_job_state::state::ai_rinse%]",
"ai_spin": "[%key:component::smartthings::entity::sensor::washer_job_state::state::ai_spin%]",
"ai_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::ai_wash%]",
"air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]",
"cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]",
"delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]",
"drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]",
"finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]",
"freeze_protection": "[%key:component::smartthings::entity::sensor::washer_job_state::state::freeze_protection%]",
"none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]",
"pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]",
"rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]",
"spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]",
"wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]",
"weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]",
"wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]"
}
},
"washer_sub_machine_state": {
"name": "Upper washer machine state",
"state": {
"pause": "[%key:common::state::paused%]",
"run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
"stop": "[%key:common::state::stopped%]"
}
},
"water_consumption": {
"name": "Water consumption"
},

View File

@@ -231,6 +231,10 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC)
self._tilt_optimistic = tilt_optimistic or not self._tilt_template
self._position: int | None = None
self._is_opening = False
self._is_closing = False
self._tilt_value: int | None = None
# The config requires (open and close scripts) or a set position script,
# therefore the base supported features will always include them.
@@ -254,54 +258,82 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
if self._attr_current_cover_position is None:
if self._position is None:
return None
return self._attr_current_cover_position == 0
return self._position == 0
@property
def is_opening(self) -> bool:
"""Return if the cover is currently opening."""
return self._is_opening
@property
def is_closing(self) -> bool:
"""Return if the cover is currently closing."""
return self._is_closing
@property
def current_cover_position(self) -> int | None:
"""Return current position of cover.
None is unknown, 0 is closed, 100 is fully open.
"""
if self._position_template or POSITION_ACTION in self._action_scripts:
return self._position
return None
@property
def current_cover_tilt_position(self) -> int | None:
"""Return current position of cover tilt.
None is unknown, 0 is closed, 100 is fully open.
"""
return self._tilt_value
@callback
def _update_position(self, result):
if result is None:
self._attr_current_cover_position = None
self._position = None
return
try:
state = float(result)
except ValueError as err:
_LOGGER.error(err)
self._attr_current_cover_position = None
self._position = None
return
if state < 0 or state > 100:
self._attr_current_cover_position = None
self._position = None
_LOGGER.error(
"Cover position value must be between 0 and 100. Value was: %.2f",
state,
)
else:
self._attr_current_cover_position = state
self._position = state
@callback
def _update_tilt(self, result):
if result is None:
self._attr_current_cover_tilt_position = None
self._tilt_value = None
return
try:
state = float(result)
except ValueError as err:
_LOGGER.error(err)
self._attr_current_cover_tilt_position = None
self._tilt_value = None
return
if state < 0 or state > 100:
self._attr_current_cover_tilt_position = None
self._tilt_value = None
_LOGGER.error(
"Tilt value must be between 0 and 100. Value was: %.2f",
state,
)
else:
self._attr_current_cover_tilt_position = state
self._tilt_value = state
def _update_opening_and_closing(self, result: Any) -> None:
state = str(result).lower()
@@ -309,12 +341,12 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
if state in _VALID_STATES:
if not self._position_template:
if state in ("true", OPEN_STATE):
self._attr_current_cover_position = 100
self._position = 100
else:
self._attr_current_cover_position = 0
self._position = 0
self._attr_is_opening = state == OPENING_STATE
self._attr_is_closing = state == CLOSING_STATE
self._is_opening = state == OPENING_STATE
self._is_closing = state == CLOSING_STATE
else:
_LOGGER.error(
"Received invalid cover is_on state: %s for entity %s. Expected: %s",
@@ -323,10 +355,10 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
", ".join(_VALID_STATES),
)
if not self._position_template:
self._attr_current_cover_position = None
self._position = None
self._attr_is_opening = False
self._attr_is_closing = False
self._is_opening = False
self._is_closing = False
async def async_open_cover(self, **kwargs: Any) -> None:
"""Move the cover up."""
@@ -339,7 +371,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
context=self._context,
)
if self._attr_assumed_state:
self._attr_current_cover_position = 100
self._position = 100
self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None:
@@ -353,7 +385,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
context=self._context,
)
if self._attr_assumed_state:
self._attr_current_cover_position = 0
self._position = 0
self.async_write_ha_state()
async def async_stop_cover(self, **kwargs: Any) -> None:
@@ -363,10 +395,10 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Set cover position."""
self._attr_current_cover_position = kwargs[ATTR_POSITION]
self._position = kwargs[ATTR_POSITION]
await self.async_run_script(
self._action_scripts[POSITION_ACTION],
run_variables={"position": self._attr_current_cover_position},
run_variables={"position": self._position},
context=self._context,
)
if self._attr_assumed_state:
@@ -374,10 +406,10 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Tilt the cover open."""
self._attr_current_cover_tilt_position = 100
self._tilt_value = 100
await self.async_run_script(
self._action_scripts[TILT_ACTION],
run_variables={"tilt": self._attr_current_cover_tilt_position},
run_variables={"tilt": self._tilt_value},
context=self._context,
)
if self._tilt_optimistic:
@@ -385,10 +417,10 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Tilt the cover closed."""
self._attr_current_cover_tilt_position = 0
self._tilt_value = 0
await self.async_run_script(
self._action_scripts[TILT_ACTION],
run_variables={"tilt": self._attr_current_cover_tilt_position},
run_variables={"tilt": self._tilt_value},
context=self._context,
)
if self._tilt_optimistic:
@@ -396,10 +428,10 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
self._attr_current_cover_tilt_position = kwargs[ATTR_TILT_POSITION]
self._tilt_value = kwargs[ATTR_TILT_POSITION]
await self.async_run_script(
self._action_scripts[TILT_ACTION],
run_variables={"tilt": self._attr_current_cover_tilt_position},
run_variables={"tilt": self._tilt_value},
context=self._context,
)
if self._tilt_optimistic:
@@ -435,11 +467,11 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover):
"""Set up templates."""
if self._template:
self.add_template_attribute(
"_attr_current_cover_position", self._template, None, self._update_state
"_position", self._template, None, self._update_state
)
if self._position_template:
self.add_template_attribute(
"_attr_current_cover_position",
"_position",
self._position_template,
None,
self._update_position,
@@ -447,7 +479,7 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover):
)
if self._tilt_template:
self.add_template_attribute(
"_attr_current_cover_tilt_position",
"_tilt_value",
self._tilt_template,
None,
self._update_tilt,
@@ -459,7 +491,7 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover):
def _update_state(self, result):
super()._update_state(result)
if isinstance(result, TemplateError):
self._attr_current_cover_position = None
self._position = None
return
self._update_opening_and_closing(result)

View File

@@ -144,7 +144,13 @@ async def async_predict_common_control(
if not service_data:
continue
entity_ids: str | list[str] | None = service_data.get("entity_id")
entity_ids: str | list[str] | None
if (target := service_data.get("target")) and (
target_entity_ids := target.get("entity_id")
):
entity_ids = target_entity_ids
else:
entity_ids = service_data.get("entity_id")
# No entity IDs found, skip this event
if entity_ids is None:

View File

@@ -1,20 +0,0 @@
"""Diagnostics support for WAQI."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import WAQIConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: WAQIConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
subentry_id: asdict(coordinator.data)
for subentry_id, coordinator in entry.runtime_data.items()
}

View File

@@ -56,6 +56,8 @@ from homeassistant.core import (
)
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
location as loc_helper,
@@ -76,7 +78,7 @@ from .context import (
template_context_manager,
template_cv,
)
from .helpers import raise_no_default
from .helpers import raise_no_default, resolve_area_id
from .render_info import RenderInfo, render_info_cv
if TYPE_CHECKING:
@@ -1242,6 +1244,103 @@ def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | N
return None
def areas(hass: HomeAssistant) -> Iterable[str | None]:
"""Return all areas."""
return list(ar.async_get(hass).areas)
def area_id(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the area ID from an area name, alias, device id, or entity id."""
return resolve_area_id(hass, lookup_value)
def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str:
"""Get area name from valid area ID."""
area = area_reg.async_get_area(valid_area_id)
assert area
return area.name
def area_name(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the area name from an area id, device id, or entity id."""
area_reg = ar.async_get(hass)
if area := area_reg.async_get_area(lookup_value):
return area.name
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
except vol.Invalid:
pass
else:
if entity := ent_reg.async_get(lookup_value):
# If entity has an area ID, get the area name for that
if entity.area_id:
return _get_area_name(area_reg, entity.area_id)
# If entity has a device ID and the device exists with an area ID, get the
# area name for that
if (
entity.device_id
and (device := dev_reg.async_get(entity.device_id))
and device.area_id
):
return _get_area_name(area_reg, device.area_id)
if (device := dev_reg.async_get(lookup_value)) and device.area_id:
return _get_area_name(area_reg, device.area_id)
return None
def area_entities(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]:
"""Return entities for a given area ID or name."""
_area_id: str | None
# if area_name returns a value, we know the input was an ID, otherwise we
# assume it's a name, and if it's neither, we return early
if area_name(hass, area_id_or_name) is None:
_area_id = area_id(hass, area_id_or_name)
else:
_area_id = area_id_or_name
if _area_id is None:
return []
ent_reg = er.async_get(hass)
entity_ids = [
entry.entity_id for entry in er.async_entries_for_area(ent_reg, _area_id)
]
dev_reg = dr.async_get(hass)
# We also need to add entities tied to a device in the area that don't themselves
# have an area specified since they inherit the area from the device.
entity_ids.extend(
[
entity.entity_id
for device in dr.async_entries_for_area(dev_reg, _area_id)
for entity in er.async_entries_for_device(ent_reg, device.id)
if entity.area_id is None
]
)
return entity_ids
def area_devices(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]:
"""Return device IDs for a given area ID or name."""
_area_id: str | None
# if area_name returns a value, we know the input was an ID, otherwise we
# assume it's a name, and if it's neither, we return early
if area_name(hass, area_id_or_name) is not None:
_area_id = area_id_or_name
else:
_area_id = area_id(hass, area_id_or_name)
if _area_id is None:
return []
dev_reg = dr.async_get(hass)
entries = dr.async_entries_for_area(dev_reg, _area_id)
return [entry.id for entry in entries]
def closest(hass: HomeAssistant, *args: Any) -> State | None:
"""Find closest entity.
@@ -2083,7 +2182,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
] = weakref.WeakValueDictionary()
self.add_extension("jinja2.ext.loopcontrols")
self.add_extension("jinja2.ext.do")
self.add_extension("homeassistant.helpers.template.extensions.AreaExtension")
self.add_extension("homeassistant.helpers.template.extensions.Base64Extension")
self.add_extension(
"homeassistant.helpers.template.extensions.CollectionExtension"
@@ -2178,6 +2276,22 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
return jinja_context(wrapper)
# Area extensions
self.globals["areas"] = hassfunction(areas)
self.globals["area_id"] = hassfunction(area_id)
self.filters["area_id"] = self.globals["area_id"]
self.globals["area_name"] = hassfunction(area_name)
self.filters["area_name"] = self.globals["area_name"]
self.globals["area_entities"] = hassfunction(area_entities)
self.filters["area_entities"] = self.globals["area_entities"]
self.globals["area_devices"] = hassfunction(area_devices)
self.filters["area_devices"] = self.globals["area_devices"]
# Integration extensions
self.globals["integration_entities"] = hassfunction(integration_entities)

View File

@@ -1,6 +1,5 @@
"""Home Assistant template extensions."""
from .areas import AreaExtension
from .base64 import Base64Extension
from .collection import CollectionExtension
from .crypto import CryptoExtension
@@ -12,7 +11,6 @@ from .regex import RegexExtension
from .string import StringExtension
__all__ = [
"AreaExtension",
"Base64Extension",
"CollectionExtension",
"CryptoExtension",

View File

@@ -1,159 +0,0 @@
"""Area functions for Home Assistant templates."""
from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.template.helpers import resolve_area_id
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
class AreaExtension(BaseTemplateExtension):
"""Extension for area-related template functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the area extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"areas",
self.areas,
as_global=True,
requires_hass=True,
),
TemplateFunction(
"area_id",
self.area_id,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"area_name",
self.area_name,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"area_entities",
self.area_entities,
as_global=True,
as_filter=True,
requires_hass=True,
),
TemplateFunction(
"area_devices",
self.area_devices,
as_global=True,
as_filter=True,
requires_hass=True,
),
],
)
def areas(self) -> Iterable[str | None]:
"""Return all areas."""
return list(ar.async_get(self.hass).areas)
def area_id(self, lookup_value: str) -> str | None:
"""Get the area ID from an area name, alias, device id, or entity id."""
return resolve_area_id(self.hass, lookup_value)
def _get_area_name(self, area_reg: ar.AreaRegistry, valid_area_id: str) -> str:
"""Get area name from valid area ID."""
area = area_reg.async_get_area(valid_area_id)
assert area
return area.name
def area_name(self, lookup_value: str) -> str | None:
"""Get the area name from an area id, device id, or entity id."""
area_reg = ar.async_get(self.hass)
if area := area_reg.async_get_area(lookup_value):
return area.name
dev_reg = dr.async_get(self.hass)
ent_reg = er.async_get(self.hass)
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
except vol.Invalid:
pass
else:
if entity := ent_reg.async_get(lookup_value):
# If entity has an area ID, get the area name for that
if entity.area_id:
return self._get_area_name(area_reg, entity.area_id)
# If entity has a device ID and the device exists with an area ID, get the
# area name for that
if (
entity.device_id
and (device := dev_reg.async_get(entity.device_id))
and device.area_id
):
return self._get_area_name(area_reg, device.area_id)
if (device := dev_reg.async_get(lookup_value)) and device.area_id:
return self._get_area_name(area_reg, device.area_id)
return None
def area_entities(self, area_id_or_name: str) -> Iterable[str]:
"""Return entities for a given area ID or name."""
_area_id: str | None
# if area_name returns a value, we know the input was an ID, otherwise we
# assume it's a name, and if it's neither, we return early
if self.area_name(area_id_or_name) is None:
_area_id = self.area_id(area_id_or_name)
else:
_area_id = area_id_or_name
if _area_id is None:
return []
ent_reg = er.async_get(self.hass)
entity_ids = [
entry.entity_id for entry in er.async_entries_for_area(ent_reg, _area_id)
]
dev_reg = dr.async_get(self.hass)
# We also need to add entities tied to a device in the area that don't themselves
# have an area specified since they inherit the area from the device.
entity_ids.extend(
[
entity.entity_id
for device in dr.async_entries_for_area(dev_reg, _area_id)
for entity in er.async_entries_for_device(ent_reg, device.id)
if entity.area_id is None
]
)
return entity_ids
def area_devices(self, area_id_or_name: str) -> Iterable[str]:
"""Return device IDs for a given area ID or name."""
_area_id: str | None
# if area_name returns a value, we know the input was an ID, otherwise we
# assume it's a name, and if it's neither, we return early
if self.area_name(area_id_or_name) is not None:
_area_id = area_id_or_name
else:
_area_id = self.area_id(area_id_or_name)
if _area_id is None:
return []
dev_reg = dr.async_get(self.hass)
entries = dr.async_entries_for_area(dev_reg, _area_id)
return [entry.id for entry in entries]

View File

@@ -39,7 +39,7 @@ habluetooth==5.7.0
hass-nabucasa==1.5.1
hassil==3.4.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251105.1
home-assistant-frontend==20251105.0
home-assistant-intents==2025.11.7
httpx==0.28.1
ifaddr==0.2.0
@@ -210,6 +210,10 @@ aiofiles>=24.1.0
# https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2
# rpds-py frequently updates cargo causing build failures
# No wheels upstream available for armhf & armv7
rpds-py==0.26.0
# Constraint num2words to 0.5.14 as 0.5.15 and 0.5.16 were removed from PyPI
num2words==0.5.14

8
requirements_all.txt generated
View File

@@ -1188,7 +1188,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251105.1
home-assistant-frontend==20251105.0
# homeassistant.components.conversation
home-assistant-intents==2025.11.7
@@ -2132,7 +2132,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==2.2.0
pylamarzocco==2.1.3
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2387,7 +2387,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
pysmartthings==3.3.4
pysmartthings==3.3.3
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2438,7 +2438,7 @@ pythinkingcleaner==0.0.3
python-MotionMount==2.3.0
# homeassistant.components.awair
python-awair==0.2.5
python-awair==0.2.4
# homeassistant.components.blockchain
python-blockchain-api==0.0.2

View File

@@ -1040,7 +1040,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251105.1
home-assistant-frontend==20251105.0
# homeassistant.components.conversation
home-assistant-intents==2025.11.7
@@ -1779,7 +1779,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==2.2.0
pylamarzocco==2.1.3
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1992,7 +1992,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
pysmartthings==3.3.4
pysmartthings==3.3.3
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2040,7 +2040,7 @@ pytautulli==23.1.1
python-MotionMount==2.3.0
# homeassistant.components.awair
python-awair==0.2.5
python-awair==0.2.4
# homeassistant.components.bsblan
python-bsblan==3.1.1

View File

@@ -52,11 +52,30 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = {
"include": INCLUDED_REQUIREMENTS_WHEELS,
"markers": {},
},
# Pandas has issues building on armhf, it is expected they
# will drop the platform in the near future (they consider it
# "flimsy" on 386). The following packages depend on pandas,
# so we comment them out.
"wheels_armhf": {
"exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"},
"include": INCLUDED_REQUIREMENTS_WHEELS,
"markers": {},
},
"wheels_armv7": {
"exclude": set(),
"include": INCLUDED_REQUIREMENTS_WHEELS,
"markers": {},
},
"wheels_amd64": {
"exclude": set(),
"include": INCLUDED_REQUIREMENTS_WHEELS,
"markers": {},
},
"wheels_i386": {
"exclude": set(),
"include": INCLUDED_REQUIREMENTS_WHEELS,
"markers": {},
},
}
URL_PIN = (
@@ -202,6 +221,10 @@ aiofiles>=24.1.0
# https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2
# rpds-py frequently updates cargo causing build failures
# No wheels upstream available for armhf & armv7
rpds-py==0.26.0
# Constraint num2words to 0.5.14 as 0.5.15 and 0.5.16 were removed from PyPI
num2words==0.5.14

View File

@@ -35,6 +35,8 @@ ARG BUILD_ARCH
RUN \
case "${{BUILD_ARCH}}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \

View File

@@ -11,7 +11,6 @@ from homeassistant.components.cloud import (
CloudNotAvailable,
CloudNotConnected,
async_get_or_create_cloudhook,
async_listen_cloudhook_change,
async_listen_connection_change,
async_remote_ui_url,
)
@@ -312,149 +311,3 @@ async def test_cloud_logout(
await hass.async_block_till_done()
assert cloud.is_logged_in is False
async def test_async_listen_cloudhook_change(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
) -> None:
"""Test async_listen_cloudhook_change."""
assert await async_setup_component(hass, "cloud", {"cloud": {}})
await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
webhook_id = "mock-webhook-id"
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"
# Set up initial cloudhooks state
await set_cloud_prefs(
{
PREF_CLOUDHOOKS: {
webhook_id: {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
}
}
)
# Track cloudhook changes
changes = []
changeInvoked = False
def on_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook change."""
nonlocal changeInvoked
changes.append(cloudhook)
changeInvoked = True
# Register the change listener
unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change)
# Verify no changes yet
assert len(changes) == 0
assert changeInvoked is False
# Delete the cloudhook by updating prefs
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
await hass.async_block_till_done()
# Verify deletion callback was called with None
assert len(changes) == 1
assert changes[-1] is None
assert changeInvoked is True
# Reset changeInvoked to detect next change
changeInvoked = False
# Add cloudhook back
cloudhook_data = {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
await hass.async_block_till_done()
# Verify callback called with cloudhook data
assert len(changes) == 2
assert changes[-1] == cloudhook_data
assert changeInvoked is True
# Reset changeInvoked to detect next change
changeInvoked = False
# Update cloudhook data with same cloudhook should not trigger callback
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
await hass.async_block_till_done()
assert changeInvoked is False
# Unsubscribe from listener
unsubscribe()
# Delete cloudhook again
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
await hass.async_block_till_done()
# Verify change callback was NOT called after unsubscribe
assert len(changes) == 2
assert changeInvoked is False
async def test_async_listen_cloudhook_change_cloud_setup_later(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
) -> None:
"""Test async_listen_cloudhook_change works when cloud is set up after listener registration."""
webhook_id = "mock-webhook-id"
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"
# Track cloudhook changes
changes: list[dict[str, Any] | None] = []
def on_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook change."""
changes.append(cloudhook)
# Register listener BEFORE cloud is set up
unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change)
# Verify it returns a callable
assert callable(unsubscribe)
# No changes yet since cloud isn't set up
assert len(changes) == 0
# Now set up cloud
assert await async_setup_component(hass, "cloud", {"cloud": {}})
await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
# Add a cloudhook - this should trigger the listener
cloudhook_data = {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
await hass.async_block_till_done()
# Verify the listener received the update
assert len(changes) == 1
assert changes[-1] == cloudhook_data
# Unsubscribe and verify no more updates
unsubscribe()
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
await hass.async_block_till_done()
# Should not receive update after unsubscribe
assert len(changes) == 1

View File

@@ -3,7 +3,7 @@
_CallList([
_Call(
tuple(
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/run/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: "127.0.0.1:11984"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
),
dict({
}),
@@ -14,7 +14,7 @@
_CallList([
_Call(
tuple(
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/run/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
),
dict({
}),

View File

@@ -3,9 +3,8 @@
from collections.abc import Awaitable, Callable
import logging
from typing import NamedTuple
from unittest.mock import ANY, AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import UnixConnector
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Stream
@@ -39,13 +38,11 @@ from homeassistant.components.go2rtc.const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_UNIX_SOCKET,
RECOMMENDED_VERSION,
)
from homeassistant.components.stream import Orientation
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_URL
from homeassistant.core import EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.typing import ConfigType
@@ -218,9 +215,7 @@ async def test_setup_go_binary(
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
def after_setup() -> None:
server.assert_called_once_with(
hass, "/usr/bin/go2rtc", ANY, enable_ui=ui_enabled
)
server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled)
server_start.assert_called_once()
await _test_setup_and_signaling(
@@ -910,53 +905,3 @@ async def test_stream_orientation_with_generic_camera(
rest_client,
"ffmpeg:https://test.stream/video.m3u8#video=h264#audio=copy#raw=-vf vflip",
)
@pytest.mark.usefixtures(
"mock_get_binary",
"mock_is_docker_env",
"mock_go2rtc_entry",
"rest_client",
"server",
)
async def test_unix_socket_connection(hass: HomeAssistant) -> None:
"""Test Unix socket is used for HA-managed go2rtc instances."""
config = {DOMAIN: {}}
with patch("homeassistant.components.go2rtc.ClientSession") as mock_session_cls:
mock_session = AsyncMock()
mock_session_cls.return_value = mock_session
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify ClientSession was created with UnixConnector
mock_session_cls.assert_called_once()
call_kwargs = mock_session_cls.call_args[1]
assert "connector" in call_kwargs
connector = call_kwargs["connector"]
assert isinstance(connector, UnixConnector)
assert connector.path == HA_MANAGED_UNIX_SOCKET
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
mock_session.close.assert_called_once()
@pytest.mark.usefixtures("rest_client", "server")
async def test_unix_socket_not_used_for_custom_server(hass: HomeAssistant) -> None:
"""Test Unix socket is not used for custom go2rtc instances."""
config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
with patch(
"homeassistant.components.go2rtc.async_get_clientsession"
) as mock_get_session:
mock_session = AsyncMock()
mock_get_session.return_value = mock_session
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify standard clientsession was used, not UnixConnector
mock_get_session.assert_called_once_with(hass)

View File

@@ -23,15 +23,9 @@ def enable_ui() -> bool:
@pytest.fixture
def mock_session() -> AsyncMock:
"""Fixture to provide a mock ClientSession."""
return AsyncMock()
@pytest.fixture
def server(hass: HomeAssistant, mock_session: AsyncMock, enable_ui: bool) -> Server:
def server(hass: HomeAssistant, enable_ui: bool) -> Server:
"""Fixture to initialize the Server."""
return Server(hass, binary=TEST_BINARY, session=mock_session, enable_ui=enable_ui)
return Server(hass, binary=TEST_BINARY, enable_ui=enable_ui)
@pytest.fixture

View File

@@ -147,7 +147,9 @@ def mock_bluetooth(enable_bluetooth: None) -> None:
@pytest.fixture
def mock_ble_device() -> BLEDevice:
"""Return a mock BLE device."""
return BLEDevice("00:00:00:00:00:00", "GS_GS012345", details={"path": "path"})
return BLEDevice(
"00:00:00:00:00:00", "GS_GS012345", details={"path": "path"}, rssi=50
)
@pytest.fixture

View File

@@ -3,7 +3,6 @@
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from bleak.backends.device import BLEDevice
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import FirmwareType, ModelName
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
@@ -197,27 +196,11 @@ async def test_config_flow_entry_migration_downgrade(
assert not await hass.config_entries.async_setup(entry.entry_id)
@pytest.mark.parametrize(
("ble_device", "has_client"),
[
(None, False),
(
BLEDevice(
address="aa:bb:cc:dd:ee:ff",
name="name",
details={},
),
True,
),
],
)
async def test_bluetooth_is_set_from_discovery(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
ble_device: BLEDevice | None,
has_client: bool,
) -> None:
"""Check we can fill a device from discovery info."""
@@ -233,17 +216,13 @@ async def test_bluetooth_is_set_from_discovery(
patch(
"homeassistant.components.lamarzocco.LaMarzoccoMachine"
) as mock_machine_class,
patch(
"homeassistant.components.lamarzocco.async_ble_device_from_address",
return_value=ble_device,
),
):
mock_machine_class.return_value = mock_lamarzocco
await async_init_integration(hass, mock_config_entry)
discovery.assert_called_once()
assert mock_machine_class.call_count == 1
_, kwargs = mock_machine_class.call_args
assert (kwargs["bluetooth_client"] is not None) == has_client
assert kwargs["bluetooth_client"] is not None
assert mock_config_entry.data[CONF_MAC] == service_info.address
assert mock_config_entry.data[CONF_TOKEN] == "token"
@@ -335,50 +314,6 @@ async def test_device(
assert device == snapshot
async def test_disconnect_on_stop(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_ble_device: BLEDevice,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we close the connection with the La Marzocco when Home Assistants stops."""
mock_config_entry = MockConfigEntry(
title="My LaMarzocco",
domain=DOMAIN,
version=4,
data=USER_INPUT
| {
CONF_MAC: mock_ble_device.address,
CONF_TOKEN: "token",
CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY,
},
unique_id=mock_lamarzocco.serial_number,
)
with (
patch(
"homeassistant.components.lamarzocco.async_ble_device_from_address",
return_value=mock_ble_device,
),
patch(
"homeassistant.components.lamarzocco.LaMarzoccoBluetoothClient",
autospec=True,
) as mock_bt_client_cls,
):
mock_bt_client = mock_bt_client_cls.return_value
mock_bt_client.disconnect = AsyncMock()
await async_init_integration(hass, mock_config_entry)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
mock_bt_client.disconnect.assert_awaited_once()
async def test_websocket_reconnects_after_termination(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@@ -113,6 +113,7 @@ async def integration_fixture(
"light_sensor",
"microwave_oven",
"mock_lock",
"mock_thermostat",
"mounted_dimmable_load_control_fixture",
"multi_endpoint_light",
"occupancy_sensor",

View File

@@ -0,0 +1,526 @@
{
"node_id": 150,
"date_commissioned": "2025-11-18T06:53:08.679289",
"last_interview": "2025-11-18T06:53:08.679325",
"interview_version": 6,
"available": true,
"is_bridge": false,
"attributes": {
"0/49/0": 1,
"0/49/1": [
{
"0": "ZW5zMzM=",
"1": true
}
],
"0/49/4": true,
"0/49/5": 0,
"0/49/6": "ZW5zMzM=",
"0/49/7": null,
"0/49/65532": 4,
"0/49/65533": 2,
"0/49/65528": [],
"0/49/65529": [],
"0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531],
"0/65/0": [],
"0/65/65532": 0,
"0/65/65533": 1,
"0/65/65528": [],
"0/65/65529": [],
"0/65/65531": [0, 65532, 65533, 65528, 65529, 65531],
"0/63/0": [],
"0/63/1": [],
"0/63/2": 4,
"0/63/3": 3,
"0/63/65532": 0,
"0/63/65533": 2,
"0/63/65528": [5, 2],
"0/63/65529": [0, 1, 3, 4],
"0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/62/0": [
{
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRlhgkBwEkCAEwCUEE2p7AKvoklmZUFHB0JFUiCsv5FCm0dmeH35yXz4UUH4HAWUwpbeU+R7hMGbAITM3T1R/mVWYthssdVcPNsfIVcjcKNQEoARgkAgE2AwQCBAEYMAQUQbZ3toX8hpE/FmJz7M6xHTbh6RMwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0DughBITJJHW/pS7o0J6o6FYTe1ufe0vCpaCj3qYeWb/QxLUydUaJQbce5Z3lUcFeHybUa/M9HID+0PRp2Ker3/GA==",
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY",
"254": 1
}
],
"0/62/1": [
{
"1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=",
"2": 4939,
"3": 2,
"4": 150,
"5": "ha",
"254": 1
}
],
"0/62/2": 16,
"0/62/3": 1,
"0/62/4": [
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y"
],
"0/62/5": 1,
"0/62/65532": 0,
"0/62/65533": 2,
"0/62/65528": [1, 3, 5, 8, 14],
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13],
"0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531],
"0/60/0": 0,
"0/60/1": null,
"0/60/2": null,
"0/60/65532": 0,
"0/60/65533": 1,
"0/60/65528": [],
"0/60/65529": [0, 2],
"0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531],
"0/55/2": 425,
"0/55/3": 61,
"0/55/4": 0,
"0/55/5": 0,
"0/55/6": 0,
"0/55/7": null,
"0/55/1": true,
"0/55/0": 2,
"0/55/8": 16,
"0/55/65532": 3,
"0/55/65533": 1,
"0/55/65528": [],
"0/55/65529": [0],
"0/55/65531": [
2, 3, 4, 5, 6, 7, 1, 0, 8, 65532, 65533, 65528, 65529, 65531
],
"0/54/0": null,
"0/54/1": null,
"0/54/2": 3,
"0/54/3": null,
"0/54/4": null,
"0/54/5": null,
"0/54/12": null,
"0/54/6": null,
"0/54/7": null,
"0/54/8": null,
"0/54/9": null,
"0/54/10": null,
"0/54/11": null,
"0/54/65532": 3,
"0/54/65533": 1,
"0/54/65528": [],
"0/54/65529": [0],
"0/54/65531": [
0, 1, 2, 3, 4, 5, 12, 6, 7, 8, 9, 10, 11, 65532, 65533, 65528, 65529,
65531
],
"0/52/0": [
{
"0": 6163,
"1": "6163"
},
{
"0": 6162,
"1": "6162"
},
{
"0": 6161,
"1": "6161"
},
{
"0": 6160,
"1": "6160"
},
{
"0": 6159,
"1": "6159"
}
],
"0/52/1": 545392,
"0/52/2": 650640,
"0/52/3": 650640,
"0/52/65532": 1,
"0/52/65533": 1,
"0/52/65528": [],
"0/52/65529": [0],
"0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/51/0": [
{
"0": "docker0",
"1": false,
"2": null,
"3": null,
"4": "8mJ0KirG",
"5": ["rBEAAQ=="],
"6": [],
"7": 0
},
{
"0": "ens33",
"1": true,
"2": null,
"3": null,
"4": "AAwpaqXN",
"5": ["wKgBxA=="],
"6": [
"KgEOCgKzOZAcmuLd4EsaUA==",
"KgEOCgKzOZA2wMm9YG06Ag==",
"/oAAAAAAAACluAo+qvkuxw=="
],
"7": 2
},
{
"0": "lo",
"1": true,
"2": null,
"3": null,
"4": "AAAAAAAA",
"5": ["fwAAAQ=="],
"6": ["AAAAAAAAAAAAAAAAAAAAAQ=="],
"7": 0
}
],
"0/51/1": 1,
"0/51/8": false,
"0/51/3": 0,
"0/51/4": 0,
"0/51/2": 16,
"0/51/65532": 0,
"0/51/65533": 2,
"0/51/65528": [2],
"0/51/65529": [0, 1],
"0/51/65531": [0, 1, 8, 3, 4, 2, 65532, 65533, 65528, 65529, 65531],
"0/50/65532": 0,
"0/50/65533": 1,
"0/50/65528": [1],
"0/50/65529": [0],
"0/50/65531": [65532, 65533, 65528, 65529, 65531],
"0/48/0": 0,
"0/48/1": {
"0": 60,
"1": 900
},
"0/48/2": 0,
"0/48/3": 2,
"0/48/4": true,
"0/48/65532": 0,
"0/48/65533": 2,
"0/48/65528": [1, 3, 5],
"0/48/65529": [0, 2, 4],
"0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531],
"0/43/0": "en-US",
"0/43/1": ["en-US"],
"0/43/65532": 0,
"0/43/65533": 1,
"0/43/65528": [],
"0/43/65529": [],
"0/43/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"0/40/0": 19,
"0/40/1": "TEST_VENDOR",
"0/40/2": 65521,
"0/40/3": "Mock Thermostat",
"0/40/4": 32769,
"0/40/5": "",
"0/40/6": "**REDACTED**",
"0/40/7": 0,
"0/40/8": "TEST_VERSION",
"0/40/9": 1,
"0/40/10": "1.0",
"0/40/19": {
"0": 3,
"1": 65535
},
"0/40/21": 17104896,
"0/40/22": 1,
"0/40/24": 1,
"0/40/11": "20200101",
"0/40/12": "",
"0/40/13": "",
"0/40/14": "",
"0/40/15": "TEST_SN",
"0/40/16": false,
"0/40/18": "29DB8B9DB518F05F",
"0/40/65532": 0,
"0/40/65533": 5,
"0/40/65528": [],
"0/40/65529": [],
"0/40/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 24, 11, 12, 13, 14, 15, 16,
18, 65532, 65533, 65528, 65529, 65531
],
"0/31/0": [
{
"1": 5,
"2": 2,
"3": [112233],
"4": null,
"254": 1
}
],
"0/31/2": 4,
"0/31/3": 3,
"0/31/4": 4,
"0/31/65532": 0,
"0/31/65533": 3,
"0/31/65528": [],
"0/31/65529": [],
"0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531],
"0/30/0": [],
"0/30/65532": 0,
"0/30/65533": 1,
"0/30/65528": [],
"0/30/65529": [],
"0/30/65531": [0, 65532, 65533, 65528, 65529, 65531],
"0/29/0": [
{
"0": 18,
"1": 1
},
{
"0": 22,
"1": 3
}
],
"0/29/1": [
49, 65, 63, 62, 60, 55, 54, 52, 51, 50, 48, 43, 40, 31, 30, 29, 3, 42, 45,
53
],
"0/29/2": [41],
"0/29/3": [1],
"0/29/65532": 0,
"0/29/65533": 3,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/3/0": 0,
"0/3/1": 2,
"0/3/65532": 0,
"0/3/65533": 6,
"0/3/65528": [],
"0/3/65529": [0, 64],
"0/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"0/42/0": [],
"0/42/1": true,
"0/42/2": 0,
"0/42/3": 0,
"0/42/65532": 0,
"0/42/65533": 1,
"0/42/65528": [],
"0/42/65529": [0],
"0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/45/0": 1,
"0/45/65532": 1,
"0/45/65533": 2,
"0/45/65528": [],
"0/45/65529": [],
"0/45/65531": [0, 65532, 65533, 65528, 65529, 65531],
"0/53/0": null,
"0/53/1": null,
"0/53/2": null,
"0/53/3": null,
"0/53/4": null,
"0/53/5": null,
"0/53/6": 0,
"0/53/7": [],
"0/53/8": [],
"0/53/9": null,
"0/53/10": null,
"0/53/11": null,
"0/53/12": null,
"0/53/13": null,
"0/53/14": 0,
"0/53/15": 0,
"0/53/16": 0,
"0/53/17": 0,
"0/53/18": 0,
"0/53/19": 0,
"0/53/20": 0,
"0/53/21": 0,
"0/53/22": 0,
"0/53/23": 0,
"0/53/24": 0,
"0/53/25": 0,
"0/53/26": 0,
"0/53/27": 0,
"0/53/28": 0,
"0/53/29": 0,
"0/53/30": 0,
"0/53/31": 0,
"0/53/32": 0,
"0/53/33": 0,
"0/53/34": 0,
"0/53/35": 0,
"0/53/36": 0,
"0/53/37": 0,
"0/53/38": 0,
"0/53/39": 0,
"0/53/40": 0,
"0/53/41": 0,
"0/53/42": 0,
"0/53/43": 0,
"0/53/44": 0,
"0/53/45": 0,
"0/53/46": 0,
"0/53/47": 0,
"0/53/48": 0,
"0/53/49": 0,
"0/53/50": 0,
"0/53/51": 0,
"0/53/52": 0,
"0/53/53": 0,
"0/53/54": 0,
"0/53/55": 0,
"0/53/56": null,
"0/53/57": null,
"0/53/58": null,
"0/53/59": null,
"0/53/60": null,
"0/53/61": null,
"0/53/62": [],
"0/53/65532": 15,
"0/53/65533": 3,
"0/53/65528": [],
"0/53/65529": [0],
"0/53/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38,
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
57, 58, 59, 60, 61, 62, 65532, 65533, 65528, 65529, 65531
],
"1/29/0": [
{
"0": 769,
"1": 4
}
],
"1/29/1": [29, 3, 4, 513, 516],
"1/29/2": [3],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 3,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"1/3/0": 0,
"1/3/1": 2,
"1/3/65532": 0,
"1/3/65533": 6,
"1/3/65528": [],
"1/3/65529": [0, 64],
"1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"1/4/0": 128,
"1/4/65532": 1,
"1/4/65533": 4,
"1/4/65528": [0, 1, 2, 3],
"1/4/65529": [0, 1, 2, 3, 4, 5],
"1/4/65531": [0, 65532, 65533, 65528, 65529, 65531],
"1/513/0": 1800,
"1/513/1": 500,
"1/513/3": 700,
"1/513/4": 3000,
"1/513/5": 1600,
"1/513/6": 3200,
"1/513/7": 0,
"1/513/8": 25,
"1/513/16": 0,
"1/513/17": 2600,
"1/513/18": 2000,
"1/513/21": 700,
"1/513/22": 3000,
"1/513/23": 1600,
"1/513/24": 3200,
"1/513/25": 25,
"1/513/26": 0,
"1/513/27": 4,
"1/513/28": 1,
"1/513/30": 4,
"1/513/35": 0,
"1/513/36": 0,
"1/513/37": 0,
"1/513/41": 1,
"1/513/48": 0,
"1/513/49": 150,
"1/513/50": 1761951600,
"1/513/72": [
{
"0": 1,
"1": 1,
"2": 1
},
{
"0": 2,
"1": 1,
"2": 1
},
{
"0": 3,
"1": 1,
"2": 2
},
{
"0": 4,
"1": 1,
"2": 2
},
{
"0": 5,
"1": 1,
"2": 2
},
{
"0": 254,
"1": 1,
"2": 2
}
],
"1/513/73": [
{
"0": 4,
"1": 1,
"2": 2
},
{
"0": 3,
"1": 1,
"2": 2
}
],
"1/513/74": 5,
"1/513/78": null,
"1/513/80": [
{
"0": "AQ==",
"1": 1,
"3": 2500,
"4": 2100,
"5": true
},
{
"0": "Ag==",
"1": 2,
"3": 2600,
"4": 2000,
"5": true
}
],
"1/513/82": 0,
"1/513/83": 5,
"1/513/84": [],
"1/513/85": null,
"1/513/86": null,
"1/513/65532": 419,
"1/513/65533": 9,
"1/513/65528": [2, 253],
"1/513/65529": [0, 6, 7, 8, 254],
"1/513/65531": [
0, 1, 3, 4, 5, 6, 7, 8, 16, 17, 18, 21, 22, 23, 24, 25, 26, 27, 28, 30,
35, 36, 37, 41, 48, 49, 50, 72, 73, 74, 78, 80, 82, 83, 84, 85, 86, 65532,
65533, 65528, 65529, 65531
],
"1/516/0": 0,
"1/516/1": 0,
"1/516/65532": 0,
"1/516/65533": 2,
"1/516/65528": [],
"1/516/65529": [],
"1/516/65531": [0, 1, 65532, 65533, 65528, 65529, 65531]
},
"attribute_subscriptions": []
}

View File

@@ -2290,6 +2290,104 @@
'state': 'unknown',
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_0-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.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.mock_thermostat_identify_0',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (0)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-0-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_0-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Mock Thermostat Identify (0)',
}),
'context': <ANY>,
'entity_id': 'button.mock_thermostat_identify_0',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_1-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.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.mock_thermostat_identify_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (1)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Mock Thermostat Identify (1)',
}),
'context': <ANY>,
'entity_id': 'button.mock_thermostat_identify_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[multi_endpoint_light][button.inovelli_identify_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -325,6 +325,77 @@
'state': 'cool',
})
# ---
# name: test_climates[mock_thermostat][climate.mock_thermostat-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 32.0,
'min_temp': 7.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.mock_thermostat',
'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': None,
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 387>,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
})
# ---
# name: test_climates[mock_thermostat][climate.mock_thermostat-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.0,
'friendly_name': 'Mock Thermostat',
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 32.0,
'min_temp': 7.0,
'supported_features': <ClimateEntityFeature: 387>,
'target_temp_high': 26.0,
'target_temp_low': 20.0,
'temperature': None,
}),
'context': <ANY>,
'entity_id': 'climate.mock_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat_cool',
})
# ---
# name: test_climates[room_airconditioner][climate.room_airconditioner-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -2318,6 +2318,63 @@
'state': 'silent',
})
# ---
# name: test_selects[mock_thermostat][select.mock_thermostat_temperature_display_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.mock_thermostat_temperature_display_mode',
'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': 'Temperature display mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'temperature_display_mode',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0',
'unit_of_measurement': None,
})
# ---
# name: test_selects[mock_thermostat][select.mock_thermostat_temperature_display_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Thermostat Temperature display mode',
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'context': <ANY>,
'entity_id': 'select.mock_thermostat_temperature_display_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Celsius',
})
# ---
# name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -7259,6 +7259,332 @@
'state': 'stopped',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_heating_demand-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': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_thermostat_heating_demand',
'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': 'Heating demand',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pi_heating_demand',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatPIHeatingDemand-513-8',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_heating_demand-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Thermostat Heating demand',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_heating_demand',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '25',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last change',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_timestamp',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSourceTimestamp-513-50',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Mock Thermostat Last change',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-10-31T23:00:00+00:00',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'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.mock_thermostat_last_change_amount',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Last change amount',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_amount',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatSetpointChangeAmount-513-49',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Last change amount',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'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.mock_thermostat_last_change_source',
'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': 'Last change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Thermostat Last change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'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.mock_thermostat_outdoor_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Outdoor temperature',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'outdoor_temperature',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Outdoor temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_outdoor_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'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.mock_thermostat_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatLocalTemperature-513-0',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18.0',
})
# ---
# name: test_sensors[multi_endpoint_light][sensor.inovelli_current_switch_position_config-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -697,3 +697,91 @@ async def test_vacuum_operational_error_sensor(
state = hass.states.get("sensor.mock_vacuum_operational_error")
assert state
assert state.state == "unknown"
@pytest.mark.parametrize("node_fixture", ["mock_thermostat"])
async def test_thermostat_setpoint_change_source(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Thermostat SetpointChangeSource sensor."""
# Thermostat Cluster / SetpointChangeSource attribute (1/513/48)
state = hass.states.get("sensor.mock_thermostat_last_change_source")
assert state
assert state.state == "manual"
assert state.attributes["options"] == ["manual", "schedule", "external"]
# Test schedule source
set_node_attribute(matter_node, 1, 513, 48, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_source")
assert state
assert state.state == "schedule"
# Test external source
set_node_attribute(matter_node, 1, 513, 48, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_source")
assert state
assert state.state == "external"
@pytest.mark.parametrize("node_fixture", ["mock_thermostat"])
async def test_thermostat_setpoint_change_timestamp(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Thermostat SetpointChangeSourceTimestamp sensor."""
# Thermostat Cluster / SetpointChangeSourceTimestamp attribute (1/513/50)
state = hass.states.get("sensor.mock_thermostat_last_change")
assert state
assert state.state == "2025-10-31T23:00:00+00:00"
# Update to a new timestamp (2024-11-15 12:00:00 UTC)
set_node_attribute(matter_node, 1, 513, 50, 1731672000)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change")
assert state
assert state.state == "2024-11-15T12:00:00+00:00"
# Test zero value (should be None/unknown)
set_node_attribute(matter_node, 1, 513, 50, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change")
assert state
assert state.state == "unknown"
@pytest.mark.parametrize("node_fixture", ["mock_thermostat"])
async def test_thermostat_setpoint_change_amount(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Thermostat SetpointChangeAmount sensor."""
# Thermostat Cluster / SetpointChangeAmount attribute (1/513/49)
state = hass.states.get("sensor.mock_thermostat_last_change_amount")
assert state
assert state.state == "1.5"
# Update to 2.0°C (200 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, 200)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_amount")
assert state
assert state.state == "2.0"
# Update to -0.5°C (-50 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, -50)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_amount")
assert state
assert state.state == "-0.5"

View File

@@ -68,9 +68,7 @@ async def _test_create_cloud_hook(
hass_admin_user: MockUser,
additional_config: dict[str, Any],
async_active_subscription_return_value: bool,
additional_steps: Callable[
[ConfigEntry, Mock, str, Callable[[Any], None]], Awaitable[None]
],
additional_steps: Callable[[ConfigEntry, Mock, str], Awaitable[None]],
) -> None:
config_entry = MockConfigEntry(
data={
@@ -86,24 +84,6 @@ async def _test_create_cloud_hook(
)
config_entry.add_to_hass(hass)
cloudhook_change_callback = None
def mock_listen_cloudhook_change(
_: HomeAssistant, _webhook_id: str, callback: Callable[[Any], None]
):
"""Mock the cloudhook change listener."""
nonlocal cloudhook_change_callback
cloudhook_change_callback = callback
return lambda: None # Return unsubscribe function
cloud_hook = "https://hook-url"
async def mock_get_or_create_cloudhook(_hass: HomeAssistant, _webhook_id: str):
"""Mock creating a cloudhook and trigger the change callback."""
assert cloudhook_change_callback is not None
cloudhook_change_callback({CONF_CLOUDHOOK_URL: cloud_hook})
return cloud_hook
with (
patch(
"homeassistant.components.cloud.async_active_subscription",
@@ -113,24 +93,17 @@ async def _test_create_cloud_hook(
patch("homeassistant.components.cloud.async_is_connected", return_value=True),
patch(
"homeassistant.components.cloud.async_get_or_create_cloudhook",
side_effect=mock_get_or_create_cloudhook,
autospec=True,
) as mock_async_get_or_create_cloudhook,
patch(
"homeassistant.components.cloud.async_listen_cloudhook_change",
side_effect=mock_listen_cloudhook_change,
),
):
cloud_hook = "https://hook-url"
mock_async_get_or_create_cloudhook.return_value = cloud_hook
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert cloudhook_change_callback is not None
await additional_steps(
config_entry,
mock_async_get_or_create_cloudhook,
cloud_hook,
cloudhook_change_callback,
config_entry, mock_async_get_or_create_cloudhook, cloud_hook
)
@@ -141,10 +114,7 @@ async def test_create_cloud_hook_on_setup(
"""Test creating a cloud hook during setup."""
async def additional_steps(
config_entry: ConfigEntry,
mock_create_cloudhook: Mock,
cloud_hook: str,
cloudhook_change_callback: Callable[[Any], None],
config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str
) -> None:
assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook
mock_create_cloudhook.assert_called_once_with(
@@ -164,10 +134,7 @@ async def test_remove_cloudhook(
"""Test removing a cloud hook when config entry is removed."""
async def additional_steps(
config_entry: ConfigEntry,
mock_create_cloudhook: Mock,
cloud_hook: str,
cloudhook_change_callback: Callable[[Any], None],
config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str
) -> None:
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook
@@ -191,10 +158,7 @@ async def test_create_cloud_hook_aleady_exists(
cloud_hook = "https://hook-url-already-exists"
async def additional_steps(
config_entry: ConfigEntry,
mock_create_cloudhook: Mock,
_: str,
cloudhook_change_callback: Callable[[Any], None],
config_entry: ConfigEntry, mock_create_cloudhook: Mock, _: str
) -> None:
assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook
mock_create_cloudhook.assert_not_called()
@@ -211,21 +175,13 @@ async def test_create_cloud_hook_after_connection(
"""Test creating a cloud hook when connected to the cloud."""
async def additional_steps(
config_entry: ConfigEntry,
mock_create_cloudhook: Mock,
cloud_hook: str,
cloudhook_change_callback: Callable[[Any], None],
config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str
) -> None:
assert CONF_CLOUDHOOK_URL not in config_entry.data
mock_create_cloudhook.assert_not_called()
async_mock_cloud_connection_status(hass, True)
await hass.async_block_till_done()
# Simulate cloudhook creation by calling the callback
cloudhook_change_callback({CONF_CLOUDHOOK_URL: cloud_hook})
await hass.async_block_till_done()
assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook
mock_create_cloudhook.assert_called_once_with(
hass, config_entry.data[CONF_WEBHOOK_ID]
@@ -304,236 +260,3 @@ async def test_remove_entry_on_user_remove(
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 0
async def test_cloudhook_cleanup_on_disconnect_and_logout(
hass: HomeAssistant,
hass_admin_user: MockUser,
) -> None:
"""Test cloudhook is cleaned up when cloud disconnects and user is logged out."""
config_entry = MockConfigEntry(
data={
**REGISTER_CLEARTEXT,
CONF_WEBHOOK_ID: "test-webhook-id",
ATTR_DEVICE_NAME: "Test",
ATTR_DEVICE_ID: "Test",
CONF_USER_ID: hass_admin_user.id,
CONF_CLOUDHOOK_URL: "https://hook-url",
},
domain=DOMAIN,
title="Test",
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.cloud.async_is_logged_in",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_active_subscription",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_is_connected",
return_value=True,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
# Cloudhook should still exist
assert CONF_CLOUDHOOK_URL in config_entry.data
# Simulate cloud disconnect and logout
with patch(
"homeassistant.components.cloud.async_is_logged_in",
return_value=False,
):
async_mock_cloud_connection_status(hass, False)
await hass.async_block_till_done()
# Cloudhook should be removed from config entry
assert CONF_CLOUDHOOK_URL not in config_entry.data
async def test_cloudhook_persists_on_disconnect_when_logged_in(
hass: HomeAssistant,
hass_admin_user: MockUser,
) -> None:
"""Test cloudhook persists when cloud disconnects but user is still logged in."""
config_entry = MockConfigEntry(
data={
**REGISTER_CLEARTEXT,
CONF_WEBHOOK_ID: "test-webhook-id",
ATTR_DEVICE_NAME: "Test",
ATTR_DEVICE_ID: "Test",
CONF_USER_ID: hass_admin_user.id,
CONF_CLOUDHOOK_URL: "https://hook-url",
},
domain=DOMAIN,
title="Test",
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.cloud.async_is_logged_in",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_active_subscription",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_is_connected",
return_value=True,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
# Cloudhook should exist
assert CONF_CLOUDHOOK_URL in config_entry.data
# Simulate cloud disconnect while still logged in
async_mock_cloud_connection_status(hass, False)
await hass.async_block_till_done()
# Cloudhook should still exist because user is still logged in
assert CONF_CLOUDHOOK_URL in config_entry.data
async def test_cloudhook_change_listener_deletion(
hass: HomeAssistant,
hass_admin_user: MockUser,
) -> None:
"""Test cloudhook change listener removes cloudhook from config entry on deletion."""
webhook_id = "test-webhook-id"
config_entry = MockConfigEntry(
data={
**REGISTER_CLEARTEXT,
CONF_WEBHOOK_ID: webhook_id,
ATTR_DEVICE_NAME: "Test",
ATTR_DEVICE_ID: "Test",
CONF_USER_ID: hass_admin_user.id,
CONF_CLOUDHOOK_URL: "https://hook-url",
},
domain=DOMAIN,
title="Test",
)
config_entry.add_to_hass(hass)
cloudhook_change_callback = None
def mock_listen_cloudhook_change(
_: HomeAssistant, _webhook_id: str, callback: Callable[[Any], None]
):
"""Mock the cloudhook change listener."""
nonlocal cloudhook_change_callback
cloudhook_change_callback = callback
return lambda: None # Return unsubscribe function
with (
patch(
"homeassistant.components.cloud.async_is_logged_in",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_active_subscription",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_is_connected",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_listen_cloudhook_change",
side_effect=mock_listen_cloudhook_change,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
# Cloudhook should exist
assert CONF_CLOUDHOOK_URL in config_entry.data
# Change listener should have been registered
assert cloudhook_change_callback is not None
# Simulate cloudhook deletion by calling the callback with None
cloudhook_change_callback(None)
await hass.async_block_till_done()
# Cloudhook should be removed from config entry
assert CONF_CLOUDHOOK_URL not in config_entry.data
async def test_cloudhook_change_listener_update(
hass: HomeAssistant,
hass_admin_user: MockUser,
) -> None:
"""Test cloudhook change listener updates cloudhook URL in config entry."""
webhook_id = "test-webhook-id"
original_url = "https://hook-url"
config_entry = MockConfigEntry(
data={
**REGISTER_CLEARTEXT,
CONF_WEBHOOK_ID: webhook_id,
ATTR_DEVICE_NAME: "Test",
ATTR_DEVICE_ID: "Test",
CONF_USER_ID: hass_admin_user.id,
CONF_CLOUDHOOK_URL: original_url,
},
domain=DOMAIN,
title="Test",
)
config_entry.add_to_hass(hass)
cloudhook_change_callback = None
def mock_listen_cloudhook_change(hass_instance, wh_id: str, callback):
"""Mock the cloudhook change listener."""
nonlocal cloudhook_change_callback
cloudhook_change_callback = callback
return lambda: None # Return unsubscribe function
with (
patch(
"homeassistant.components.cloud.async_is_logged_in",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_active_subscription",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_is_connected",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_listen_cloudhook_change",
side_effect=mock_listen_cloudhook_change,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
# Cloudhook should exist with original URL
assert config_entry.data[CONF_CLOUDHOOK_URL] == original_url
# Change listener should have been registered
assert cloudhook_change_callback is not None
# Simulate cloudhook URL change
new_url = "https://new-hook-url"
cloudhook_change_callback({CONF_CLOUDHOOK_URL: new_url})
await hass.async_block_till_done()
# Cloudhook URL should be updated in config entry
assert config_entry.data[CONF_CLOUDHOOK_URL] == new_url
# Simulate same URL update (should not trigger update)
cloudhook_change_callback({CONF_CLOUDHOOK_URL: new_url})
await hass.async_block_till_done()
# URL should remain the same
assert config_entry.data[CONF_CLOUDHOOK_URL] == new_url

View File

@@ -310,73 +310,6 @@ async def test_webhook_handle_get_config(
assert expected_dict == json
async def test_webhook_handle_get_config_with_cloudhook_and_active_subscription(
hass: HomeAssistant,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test get_config returns cloudhook_url when there's an active subscription."""
webhook_id = create_registrations[1]["webhook_id"]
webhook_url = f"/api/webhook/{webhook_id}"
# Get the config entry and add cloudhook_url to it
config_entry = hass.config_entries.async_entries(DOMAIN)[1]
hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, "cloudhook_url": "https://hooks.nabu.casa/test"},
)
with (
patch(
"homeassistant.components.cloud.async_active_subscription",
return_value=True,
),
patch(
"homeassistant.components.cloud.async_remote_ui_url",
return_value="https://remote.ui.url",
),
):
resp = await webhook_client.post(webhook_url, json={"type": "get_config"})
assert resp.status == HTTPStatus.OK
json_resp = await resp.json()
# Cloudhook should be in response
assert "cloudhook_url" in json_resp
assert json_resp["cloudhook_url"] == "https://hooks.nabu.casa/test"
# Remote UI should also be in response
assert "remote_ui_url" in json_resp
async def test_webhook_handle_get_config_with_cloudhook_no_subscription(
hass: HomeAssistant,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test get_config doesn't return cloudhook_url without active subscription."""
webhook_id = create_registrations[1]["webhook_id"]
webhook_url = f"/api/webhook/{webhook_id}"
# Get the config entry and add cloudhook_url to it
config_entry = hass.config_entries.async_entries(DOMAIN)[1]
hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, "cloudhook_url": "https://hooks.nabu.casa/test"},
)
with patch(
"homeassistant.components.cloud.async_active_subscription",
return_value=False,
):
resp = await webhook_client.post(webhook_url, json={"type": "get_config"})
assert resp.status == HTTPStatus.OK
json_resp = await resp.json()
# Cloudhook should NOT be in response even though it exists in config entry
assert "cloudhook_url" not in json_resp
# Remote UI should also not be in response
assert "remote_ui_url" not in json_resp
async def test_webhook_returns_error_incorrect_json(
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,

View File

@@ -1,8 +1,6 @@
"""Test niko_home_control config flow."""
from unittest.mock import AsyncMock
import pytest
from unittest.mock import AsyncMock, patch
from homeassistant.components.niko_home_control.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
@@ -38,22 +36,8 @@ async def test_full_flow(
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(TimeoutError, "timeout_connect"),
(OSError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_flow_errors(
hass: HomeAssistant,
mock_niko_home_control_connection: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test the timeout error."""
async def test_cannot_connect(hass: HomeAssistant) -> None:
"""Test the cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
@@ -61,18 +45,21 @@ async def test_flow_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_niko_home_control_connection.connect.side_effect = exception
with patch(
"homeassistant.components.niko_home_control.config_flow.NHCController.connect",
side_effect=Exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.123"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_niko_home_control_connection.connect.side_effect = None
assert result["errors"] == {"base": "cannot_connect"}
with patch(
"homeassistant.components.niko_home_control.config_flow.NHCController.connect",
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.123"},
@@ -132,7 +119,6 @@ async def test_reconfigure(
hass: HomeAssistant,
mock_niko_home_control_connection: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the reconfigure flow."""
mock_config_entry.add_to_hass(hass)
@@ -150,39 +136,28 @@ async def test_reconfigure(
assert result["reason"] == "reconfigure_successful"
@pytest.mark.parametrize(
("exception", "error"),
[
(TimeoutError, "timeout_connect"),
(OSError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_reconfigure_errors(
async def test_reconfigure_cannot_connect(
hass: HomeAssistant,
mock_niko_home_control_connection: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reconfiguration with connection error."""
mock_config_entry.add_to_hass(hass)
mock_niko_home_control_connection.connect.side_effect = Exception("cannot_connect")
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_niko_home_control_connection.connect.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.122"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
assert result["errors"] == {"base": "cannot_connect"}
mock_niko_home_control_connection.connect.side_effect = None

View File

@@ -135,14 +135,12 @@ def mock_smartthings() -> Generator[AsyncMock]:
"da_wm_wm_000001",
"da_wm_wm_000001_1",
"da_wm_sc_000001",
"da_wm_dw_01011",
"da_rvc_normal_000001",
"da_rvc_map_01011",
"da_ks_microwave_0101x",
"da_ks_cooktop_31001",
"da_ks_range_0101x",
"da_ks_oven_01061",
"da_ks_oven_0107x",
"hue_color_temperature_bulb",
"hue_rgbw_color_bulb",
"c2c_shade",

View File

@@ -1,913 +0,0 @@
{
"components": {
"main": {
"samsungce.dishwasherWashingCourse": {
"customCourseCandidates": {
"value": ["plastics", "babycare", "potsAndPans"],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"washingCourse": {
"value": "eco",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"supportedCourses": {
"value": [
"auto",
"eco",
"intensive",
"delicate",
"express_0C",
"preWash",
"extraSilence",
"machineCare",
"plastics",
"babycare",
"potsAndPans"
],
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"dishwasherOperatingState": {
"completionTime": {
"value": "2025-11-15T17:51:16Z",
"timestamp": "2025-11-15T13:56:16.860Z"
},
"machineState": {
"value": "stop",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"progress": {
"value": null
},
"supportedMachineStates": {
"value": ["stop", "run", "pause"],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"dishwasherJobState": {
"value": "unknown",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"samsungce.dishwasherWashingOptions": {
"dryPlus": {
"value": null
},
"stormWash": {
"value": null
},
"multiTab": {
"value": null
},
"hotAirDry": {
"value": null
},
"selectedZone": {
"value": {
"value": "all",
"settable": ["lower", "all"]
},
"timestamp": "2025-11-13T20:13:48.823Z"
},
"speedBooster": {
"value": {
"value": false,
"settable": [false, true]
},
"timestamp": "2024-11-11T21:50:17.345Z"
},
"highTempWash": {
"value": null
},
"sanitizingWash": {
"value": null
},
"heatedDry": {
"value": null
},
"zoneBooster": {
"value": null
},
"addRinse": {
"value": null
},
"supportedList": {
"value": ["selectedZone", "speedBooster", "sanitize"],
"timestamp": "2024-11-11T21:50:17.345Z"
},
"rinsePlus": {
"value": null
},
"sanitize": {
"value": {
"value": false,
"settable": [false, true]
},
"timestamp": "2025-09-18T17:29:25.548Z"
},
"steamSoak": {
"value": null
}
},
"samsungce.deviceIdentification": {
"micomAssayCode": {
"value": "30008041",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"modelName": {
"value": null
},
"serialNumber": {
"value": null
},
"serialNumberExtra": {
"value": null
},
"modelClassificationCode": {
"value": "40000200001611004981000000200000",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"description": {
"value": "DA_DW_TP1_21_COMMON_DW8700B/DD92-0008041_0001",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"releaseYear": {
"value": 22,
"timestamp": "2025-11-15T13:19:16.373Z"
},
"binaryId": {
"value": "DA_DW_TP1_21_COMMON",
"timestamp": "2025-11-15T13:57:48.879Z"
}
},
"custom.dishwasherOperatingProgress": {
"dishwasherOperatingProgress": {
"value": "none",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"switch": {
"switch": {
"value": "off",
"timestamp": "2025-11-15T13:57:48.878Z"
}
},
"samsungce.quickControl": {
"version": {
"value": "1.0",
"timestamp": "2025-11-15T13:19:16.372Z"
}
},
"samsungce.waterConsumptionReport": {
"waterConsumption": {
"value": {
"cumulativeAmount": 1336200,
"delta": 0,
"start": "2025-11-15T13:56:40Z",
"end": "2025-11-15T13:57:48Z"
},
"timestamp": "2025-11-15T13:57:48.880Z"
}
},
"ocf": {
"st": {
"value": null
},
"mndt": {
"value": null
},
"mnfv": {
"value": "DA_DW_TP1_21_COMMON_30250513",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnhw": {
"value": "Realtek",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"di": {
"value": "7ff318f3-3772-524d-3c9f-72fcd26413ed",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnsl": {
"value": "http://www.samsung.com",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"dmv": {
"value": "res.1.1.0,sh.1.1.0",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"n": {
"value": "[dishwasher] Samsung",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnmo": {
"value": "DA_DW_TP1_21_COMMON|30008041|40000200001611004981000000200000",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"vid": {
"value": "DA-WM-DW-01011",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnmn": {
"value": "Samsung Electronics",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnml": {
"value": "http://www.samsung.com",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnpv": {
"value": "DAWIT 2.0",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"mnos": {
"value": "TizenRT 3.1",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"pi": {
"value": "7ff318f3-3772-524d-3c9f-72fcd26413ed",
"timestamp": "2025-07-26T13:20:15.202Z"
},
"icv": {
"value": "core.1.1.0",
"timestamp": "2025-07-26T13:20:15.202Z"
}
},
"samsungce.audioVolumeLevel": {
"volumeLevel": {
"value": 1,
"timestamp": "2025-11-15T13:32:41.326Z"
},
"volumeLevelRange": {
"value": {
"minimum": 0,
"maximum": 1,
"step": 1
},
"data": {},
"timestamp": "2025-11-15T13:18:42.258Z"
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [
"samsungce.operationOrigin",
"samsungce.energyPlanner",
"samsungce.autoOpenDoor",
"custom.waterFilter"
],
"timestamp": "2025-11-14T16:38:15.901Z"
}
},
"samsungce.driverVersion": {
"versionNumber": {
"value": 25090101,
"timestamp": "2025-09-22T12:24:31.169Z"
}
},
"sec.diagnosticsInformation": {
"logType": {
"value": ["errCode", "dump"],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"endpoint": {
"value": "SSM",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"minVersion": {
"value": "3.0",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"signinPermission": {
"value": null
},
"setupId": {
"value": "WD0",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"protocolType": {
"value": "ble_ocf",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"tsId": {
"value": "DA01",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"mnId": {
"value": "0AJT",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"dumpType": {
"value": "file",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"samsungce.dishwasherOperation": {
"supportedOperatingState": {
"value": ["ready", "running", "paused"],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"operatingState": {
"value": "ready",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"reservable": {
"value": false,
"timestamp": "2025-11-15T13:19:16.373Z"
},
"progressPercentage": {
"value": 1,
"timestamp": "2025-11-15T13:19:16.373Z"
},
"remainingTimeStr": {
"value": "03:55",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"operationTime": {
"value": null
},
"remainingTime": {
"value": 235.0,
"unit": "min",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"timeLeftToStart": {
"value": 0.0,
"unit": "min",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"samsungce.dishwasherJobState": {
"scheduledJobs": {
"value": [
{
"jobName": "washing",
"timeInSec": 6660
},
{
"jobName": "rinsing",
"timeInSec": 1990
},
{
"jobName": "drying",
"timeInSec": 5420
}
],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"dishwasherJobState": {
"value": "none",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"samsungce.kidsLock": {
"lockState": {
"value": "unlocked",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"demandResponseLoadControl": {
"drlcStatus": {
"value": {
"drlcType": 1,
"drlcLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
},
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"powerConsumptionReport": {
"powerConsumption": {
"value": {
"energy": 98300,
"deltaEnergy": 0,
"power": 0,
"powerEnergy": 0.0,
"persistedEnergy": 0,
"energySaved": 0,
"persistedSavedEnergy": 0,
"start": "2025-11-15T13:56:40Z",
"end": "2025-11-15T13:57:48Z"
},
"timestamp": "2025-11-15T13:57:48.880Z"
}
},
"refresh": {},
"samsungce.dishwasherWashingCourseDetails": {
"predefinedCourses": {
"value": [
{
"courseName": "auto",
"energyUsage": 4,
"waterUsage": 4,
"temperature": {
"min": 55,
"max": 62,
"unit": "C"
},
"expectedTime": {
"time": 155,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": [false, true]
},
"speedBooster": {
"default": false,
"settable": [false, true]
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "eco",
"energyUsage": 2,
"waterUsage": 2,
"temperature": {
"min": 49,
"max": 49,
"unit": "C"
},
"expectedTime": {
"time": 235,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": [false, true]
},
"speedBooster": {
"default": false,
"settable": [false, true]
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "intensive",
"energyUsage": 5,
"waterUsage": 5,
"temperature": {
"min": 65,
"max": 65,
"unit": "C"
},
"expectedTime": {
"time": 180,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": [false, true]
},
"speedBooster": {
"default": false,
"settable": [false, true]
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "delicate",
"energyUsage": 2,
"waterUsage": 3,
"temperature": {
"min": 50,
"max": 50,
"unit": "C"
},
"expectedTime": {
"time": 117,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": []
},
"speedBooster": {
"default": false,
"settable": [false, true]
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "express_0C",
"energyUsage": 3,
"waterUsage": 2,
"temperature": {
"min": 63,
"max": 63,
"unit": "C"
},
"expectedTime": {
"time": 78,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": []
},
"speedBooster": {
"default": false,
"settable": []
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "preWash",
"energyUsage": 1,
"waterUsage": 1,
"expectedTime": {
"time": 24,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": []
},
"speedBooster": {
"default": false,
"settable": []
},
"selectedZone": {
"default": "all",
"settable": ["none", "all"]
}
}
},
{
"courseName": "extraSilence",
"energyUsage": 3,
"waterUsage": 4,
"temperature": {
"min": 60,
"max": 60,
"unit": "C"
},
"expectedTime": {
"time": 263,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": [false, true]
},
"speedBooster": {
"default": false,
"settable": [false, true]
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "machineCare",
"energyUsage": 5,
"waterUsage": 4,
"temperature": {
"min": 70,
"max": 70,
"unit": "C"
},
"expectedTime": {
"time": 136,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": []
},
"speedBooster": {
"default": false,
"settable": []
},
"selectedZone": {
"default": "all",
"settable": ["none", "all"]
}
}
},
{
"courseName": "plastics",
"energyUsage": 3,
"waterUsage": 3,
"temperature": {
"min": 60,
"max": 60,
"unit": "C"
},
"expectedTime": {
"time": 97,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": []
},
"speedBooster": {
"default": false,
"settable": []
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
},
{
"courseName": "babycare",
"energyUsage": 3,
"waterUsage": 1,
"temperature": {
"min": 75,
"max": 75,
"unit": "C"
},
"expectedTime": {
"time": 68,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": []
},
"speedBooster": {
"default": false,
"settable": []
},
"selectedZone": {
"default": "all",
"settable": ["none", "all"]
}
}
},
{
"courseName": "potsAndPans",
"energyUsage": 5,
"waterUsage": 5,
"temperature": {
"min": 68,
"max": 68,
"unit": "C"
},
"expectedTime": {
"time": 151,
"unit": "min"
},
"options": {
"sanitize": {
"default": false,
"settable": [false, true]
},
"speedBooster": {
"default": false,
"settable": [false, true]
},
"selectedZone": {
"default": "all",
"settable": ["none", "lower", "all"]
}
}
}
],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"waterUsageMax": {
"value": 5,
"timestamp": "2025-11-15T13:19:16.373Z"
},
"energyUsageMax": {
"value": 5,
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"execute": {
"data": {
"value": null
}
},
"samsungce.energyPlanner": {
"data": {
"value": null
},
"plan": {
"value": null
}
},
"sec.wifiConfiguration": {
"autoReconnection": {
"value": true,
"timestamp": "2025-11-15T13:19:16.373Z"
},
"minVersion": {
"value": "1.0",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"supportedWiFiFreq": {
"value": ["2.4G"],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"supportedAuthType": {
"value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"],
"timestamp": "2025-11-15T13:19:16.373Z"
},
"protocolType": {
"value": ["helper_hotspot", "ble_ocf"],
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"custom.dishwasherOperatingPercentage": {
"dishwasherOperatingPercentage": {
"value": 1,
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"samsungce.softwareVersion": {
"versions": {
"value": [
{
"id": "0",
"swType": "Software",
"versionNumber": "00081A250513(A235)",
"description": "DA_DW_TP1_21_COMMON|30008041|40000200001611004981000000200000"
},
{
"id": "1",
"swType": "Firmware",
"versionNumber": "00080A23120568,FFFFFFFFFFFFFF",
"description": "Firmware_1_DB_30008041231205682FFFFFFFFFFFFFFFFFFFFFFFFFFE(081330008041FFFFFFFF_30000000)(FileDown:0)(Type:0)"
}
],
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"remoteControlStatus": {
"remoteControlEnabled": {
"value": "false",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"custom.supportedOptions": {
"course": {
"value": "8A",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"referenceTable": {
"value": null
},
"supportedCourses": {
"value": [
"82",
"8A",
"A7",
"85",
"0C",
"01",
"8C",
"0D",
"8E",
"8F",
"8D"
],
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"custom.dishwasherDelayStartTime": {
"dishwasherDelayStartTime": {
"value": "00:00:00",
"timestamp": "2025-11-15T13:19:16.373Z"
}
},
"samsungce.operationOrigin": {},
"custom.energyType": {
"energyType": {
"value": "2.0",
"timestamp": "2024-11-11T21:50:17.345Z"
},
"energySavingSupport": {
"value": true,
"timestamp": "2024-11-11T21:50:20.186Z"
},
"drMaxDuration": {
"value": 99999999,
"unit": "min",
"timestamp": "2024-11-11T21:50:17.345Z"
},
"energySavingLevel": {
"value": null
},
"energySavingInfo": {
"value": null
},
"supportedEnergySavingLevels": {
"value": null
},
"energySavingOperation": {
"value": false,
"timestamp": "2025-11-15T13:19:16.373Z"
},
"notificationTemplateID": {
"value": null
},
"energySavingOperationSupport": {
"value": true,
"timestamp": "2024-11-11T21:50:17.345Z"
}
},
"samsungce.autoOpenDoor": {
"autoOpenDoor": {
"value": null
},
"supportedPressureLevels": {
"value": null
},
"pressureLevel": {
"value": null
}
},
"samsungce.softwareUpdate": {
"targetModule": {
"value": {
"newVersion": "00000000",
"currentVersion": "00000000",
"moduleType": "mainController"
},
"timestamp": "2025-11-13T05:16:30.733Z"
},
"otnDUID": {
"value": "CPCB2ZD47AETC",
"timestamp": "2025-11-15T13:19:16.373Z"
},
"lastUpdatedDate": {
"value": null
},
"availableModules": {
"value": [],
"timestamp": "2025-11-13T05:16:30.733Z"
},
"newVersionAvailable": {
"value": false,
"timestamp": "2025-11-13T05:16:30.733Z"
},
"operatingState": {
"value": "none",
"timestamp": "2025-11-13T05:16:30.733Z"
},
"progress": {
"value": null
}
},
"custom.waterFilter": {
"waterFilterUsageStep": {
"value": null
},
"waterFilterResetType": {
"value": null
},
"waterFilterCapacity": {
"value": null
},
"waterFilterLastResetDate": {
"value": null
},
"waterFilterUsage": {
"value": null
},
"waterFilterStatus": {
"value": null
}
}
}
}
}

View File

@@ -1,238 +0,0 @@
{
"items": [
{
"deviceId": "199d7863-ad04-793d-176d-658f10062575",
"name": "Samsung Oven",
"label": "Kitchen oven",
"manufacturerName": "Samsung Electronics",
"presentationId": "DA-KS-OVEN-0107X",
"deviceManufacturerCode": "Samsung Electronics",
"locationId": "beea82e4-8799-4757-ac70-591d76b2b248",
"ownerId": "6feed944-1d03-0a9f-3262-060ec4f9797d",
"roomId": "f8a4ac44-7f5d-4a73-a6be-189076ec0307",
"deviceTypeName": "Samsung OCF Oven",
"components": [
{
"id": "main",
"label": "main",
"capabilities": [
{
"id": "ocf",
"version": 1
},
{
"id": "execute",
"version": 1
},
{
"id": "refresh",
"version": 1
},
{
"id": "remoteControlStatus",
"version": 1
},
{
"id": "ovenSetpoint",
"version": 1
},
{
"id": "ovenMode",
"version": 1
},
{
"id": "ovenOperatingState",
"version": 1
},
{
"id": "temperatureMeasurement",
"version": 1
},
{
"id": "samsungce.doorState",
"version": 1
},
{
"id": "samsungce.definedRecipe",
"version": 1
},
{
"id": "samsungce.deviceIdentification",
"version": 1
},
{
"id": "samsungce.driverVersion",
"version": 1
},
{
"id": "samsungce.kitchenDeviceIdentification",
"version": 1
},
{
"id": "samsungce.kitchenDeviceDefaults",
"version": 1
},
{
"id": "samsungce.ovenMode",
"version": 1
},
{
"id": "samsungce.ovenOperatingState",
"version": 1
},
{
"id": "samsungce.meatProbe",
"version": 1
},
{
"id": "samsungce.lamp",
"version": 1
},
{
"id": "samsungce.kitchenModeSpecification",
"version": 1
},
{
"id": "samsungce.kidsLock",
"version": 1
},
{
"id": "samsungce.softwareUpdate",
"version": 1
},
{
"id": "samsungce.waterReservoir",
"version": 1
},
{
"id": "samsungce.ovenDrainageRequirement",
"version": 1
},
{
"id": "samsungce.quickControl",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "sec.diagnosticsInformation",
"version": 1
},
{
"id": "sec.wifiConfiguration",
"version": 1
},
{
"id": "sec.calmConnectionCare",
"version": 1
},
{
"id": "samsungce.softwareVersion",
"version": 1
}
],
"categories": [
{
"name": "Oven",
"categoryType": "manufacturer"
}
],
"optional": false
},
{
"id": "cavity-01",
"label": "cavity-01",
"capabilities": [
{
"id": "ovenSetpoint",
"version": 1
},
{
"id": "ovenMode",
"version": 1
},
{
"id": "ovenOperatingState",
"version": 1
},
{
"id": "temperatureMeasurement",
"version": 1
},
{
"id": "samsungce.ovenMode",
"version": 1
},
{
"id": "samsungce.ovenOperatingState",
"version": 1
},
{
"id": "samsungce.definedRecipe",
"version": 1
},
{
"id": "samsungce.kitchenDeviceDefaults",
"version": 1
},
{
"id": "custom.ovenCavityStatus",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
],
"optional": false
}
],
"createTime": "2025-06-25T15:25:14.789Z",
"profile": {
"id": "5136fb87-83ab-3b57-a298-178a371d03c2"
},
"ocf": {
"ocfDeviceType": "oic.d.oven",
"name": "Samsung Oven",
"specVersion": "core.1.1.0",
"verticalDomainSpecVersion": "1.2.1",
"manufacturerName": "Samsung Electronics",
"modelNumber": "TP1X_DA-KS-OVEN-0107X|40456741|50030018001611400A00000000000000",
"platformVersion": "DAWIT 3.0",
"platformOS": "TizenRT 3.1",
"hwVersion": "Realtek",
"firmwareVersion": "AKS-WW-TP1-22-OVEN-1_40250221",
"vendorId": "DA-KS-OVEN-0107X",
"vendorResourceClientServerVersion": "Realtek Release 3.1.240221",
"lastSignupTime": "2025-06-25T15:25:10.258442968Z",
"transferCandidate": false,
"additionalAuthCodeRequired": false
},
"type": "OCF",
"restrictionTier": 0,
"allowed": [],
"indoorMap": {
"coordinates": [71.0, 40.0, 178.0],
"rotation": [0.0, 180.0, 0.0],
"visible": true,
"data": {
"expressionType": "both_2d_3d",
"lastHideTime": "0",
"lastUpdateTime": "1763171338982",
"lightingGroupId": "00000000-0000-0000-0000-000000000000"
}
},
"executionContext": "CLOUD",
"relationships": []
}
],
"_links": {}
}

View File

@@ -1,202 +0,0 @@
{
"items": [
{
"deviceId": "7ff318f3-3772-524d-3c9f-72fcd26413ed",
"name": "[dishwasher] Samsung",
"label": "Dishwasher",
"manufacturerName": "Samsung Electronics",
"presentationId": "DA-WM-DW-01011",
"deviceManufacturerCode": "Samsung Electronics",
"locationId": "beea82e4-8799-4757-ac70-591d76b2b248",
"ownerId": "6feed944-1d03-0a9f-3262-060ec4f9797d",
"roomId": "f8a4ac44-7f5d-4a73-a6be-189076ec0307",
"deviceTypeName": "Samsung OCF Dishwasher",
"components": [
{
"id": "main",
"label": "main",
"capabilities": [
{
"id": "execute",
"version": 1
},
{
"id": "ocf",
"version": 1
},
{
"id": "powerConsumptionReport",
"version": 1
},
{
"id": "refresh",
"version": 1
},
{
"id": "remoteControlStatus",
"version": 1
},
{
"id": "switch",
"version": 1
},
{
"id": "demandResponseLoadControl",
"version": 1
},
{
"id": "dishwasherOperatingState",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "custom.dishwasherOperatingProgress",
"version": 1
},
{
"id": "custom.dishwasherOperatingPercentage",
"version": 1
},
{
"id": "custom.dishwasherDelayStartTime",
"version": 1
},
{
"id": "custom.energyType",
"version": 1
},
{
"id": "custom.supportedOptions",
"version": 1
},
{
"id": "custom.waterFilter",
"version": 1
},
{
"id": "samsungce.audioVolumeLevel",
"version": 1
},
{
"id": "samsungce.autoOpenDoor",
"version": 1
},
{
"id": "samsungce.deviceIdentification",
"version": 1
},
{
"id": "samsungce.dishwasherJobState",
"version": 1
},
{
"id": "samsungce.dishwasherWashingCourse",
"version": 1
},
{
"id": "samsungce.dishwasherWashingCourseDetails",
"version": 1
},
{
"id": "samsungce.dishwasherOperation",
"version": 1
},
{
"id": "samsungce.dishwasherWashingOptions",
"version": 1
},
{
"id": "samsungce.driverVersion",
"version": 1
},
{
"id": "samsungce.energyPlanner",
"version": 1
},
{
"id": "samsungce.operationOrigin",
"version": 1
},
{
"id": "samsungce.softwareUpdate",
"version": 1
},
{
"id": "samsungce.kidsLock",
"version": 1
},
{
"id": "samsungce.waterConsumptionReport",
"version": 1
},
{
"id": "samsungce.quickControl",
"version": 1
},
{
"id": "samsungce.softwareVersion",
"version": 1
},
{
"id": "sec.diagnosticsInformation",
"version": 1
},
{
"id": "sec.wifiConfiguration",
"version": 1
}
],
"categories": [
{
"name": "Dishwasher",
"categoryType": "manufacturer"
}
],
"optional": false
}
],
"createTime": "2024-11-11T21:50:12.084Z",
"profile": {
"id": "cdd89bbe-004a-37f9-8100-a211e45688f1"
},
"ocf": {
"ocfDeviceType": "oic.d.dishwasher",
"name": "[dishwasher] Samsung",
"specVersion": "core.1.1.0",
"verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0",
"manufacturerName": "Samsung Electronics",
"modelNumber": "DA_DW_TP1_21_COMMON|30008041|40000200001611004981000000200000",
"platformVersion": "DAWIT 2.0",
"platformOS": "TizenRT 3.1",
"hwVersion": "Realtek",
"firmwareVersion": "DA_DW_TP1_21_COMMON_30250513",
"vendorId": "DA-WM-DW-01011",
"vendorResourceClientServerVersion": "Realtek Release 3.1.240801",
"lastSignupTime": "2024-11-11T21:50:12.032414997Z",
"transferCandidate": false,
"additionalAuthCodeRequired": false,
"modelCode": "DW60BG850B00ET"
},
"type": "OCF",
"restrictionTier": 0,
"allowed": [],
"indoorMap": {
"coordinates": [111.0, 0.0, 178.0],
"rotation": [0.0, 180.0, 0.0],
"visible": true,
"data": {
"expressionType": "both_2d_3d",
"lastHideTime": "0",
"lastUpdateTime": "1763171338982",
"lightingGroupId": "00000000-0000-0000-0000-000000000000"
}
},
"executionContext": "CLOUD",
"relationships": []
}
],
"_links": {}
}

View File

@@ -632,102 +632,6 @@
'state': 'on',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][binary_sensor.kitchen_oven_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.kitchen_oven_child_lock',
'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': 'Child lock',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'child_lock',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_samsungce.kidsLock_lockState_lockState',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][binary_sensor.kitchen_oven_child_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven Child lock',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.kitchen_oven_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][binary_sensor.kitchen_oven_remote_control-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.kitchen_oven_remote_control',
'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': 'Remote control',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'remote_control',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][binary_sensor.kitchen_oven_remote_control-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven Remote control',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.kitchen_oven_remote_control',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1557,151 +1461,6 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.dishwasher_child_lock',
'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': 'Child lock',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'child_lock',
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_samsungce.kidsLock_lockState_lockState',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_child_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Dishwasher Child lock',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.dishwasher_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.dishwasher_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_switch_switch_switch',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Dishwasher Power',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.dishwasher_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_remote_control-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.dishwasher_remote_control',
'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': 'Remote control',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'remote_control',
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_remote_control-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Dishwasher Remote control',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.dishwasher_remote_control',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -3103,54 +2862,6 @@
'state': 'on',
})
# ---
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_upper_washer_remote_control-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.washer_upper_washer_remote_control',
'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': 'Upper washer remote control',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sub_remote_control',
'unique_id': 'C097276D-C8D4-0000-0000-000000000000_sub_remoteControlStatus_remoteControlEnabled_remoteControlEnabled',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_upper_washer_remote_control-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Washer Upper washer remote control',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.washer_upper_washer_remote_control',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -653,37 +653,6 @@
'via_device_id': None,
})
# ---
# name: test_devices[da_ks_oven_0107x]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://account.smartthings.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': 'Realtek',
'id': <ANY>,
'identifiers': set({
tuple(
'smartthings',
'199d7863-ad04-793d-176d-658f10062575',
),
}),
'labels': set({
}),
'manufacturer': 'Samsung Electronics',
'model': 'TP1X_DA-KS-OVEN-0107X',
'model_id': None,
'name': 'Kitchen oven',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': 'AKS-WW-TP1-22-OVEN-1_40250221',
'via_device_id': None,
})
# ---
# name: test_devices[da_ks_range_0101x]
DeviceRegistryEntrySnapshot({
'area_id': None,
@@ -1025,37 +994,6 @@
'via_device_id': None,
})
# ---
# name: test_devices[da_wm_dw_01011]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://account.smartthings.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': 'Realtek',
'id': <ANY>,
'identifiers': set({
tuple(
'smartthings',
'7ff318f3-3772-524d-3c9f-72fcd26413ed',
),
}),
'labels': set({
}),
'manufacturer': 'Samsung Electronics',
'model': 'DA_DW_TP1_21_COMMON',
'model_id': None,
'name': 'Dishwasher',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': 'DA_DW_TP1_21_COMMON_30250513',
'via_device_id': None,
})
# ---
# name: test_devices[da_wm_sc_000001]
DeviceRegistryEntrySnapshot({
'area_id': None,

View File

@@ -56,64 +56,6 @@
'state': '0',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][number.kitchen_oven_rinse_cycles-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 5,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.kitchen_oven_rinse_cycles',
'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': 'Rinse cycles',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'washer_rinse_cycles',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles',
'unit_of_measurement': 'cycles',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][number.kitchen_oven_rinse_cycles-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven Rinse cycles',
'max': 5,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
'unit_of_measurement': 'cycles',
}),
'context': <ANY>,
'entity_id': 'number.kitchen_oven_rinse_cycles',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2',
})
# ---
# name: test_all_entities[da_ref_normal_000001][number.refrigerator_freezer_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -1,126 +1,4 @@
# serializer version: 1
# name: test_all_entities[da_ac_rac_000003][select.clim_salon_dust_filter_alarm_threshold-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'180',
'300',
'500',
'700',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.clim_salon_dust_filter_alarm_threshold',
'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': 'Dust filter alarm threshold',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dust_filter_alarm',
'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_samsungce.dustFilterAlarm_alarmThreshold_alarmThreshold',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ac_rac_000003][select.clim_salon_dust_filter_alarm_threshold-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Clim Salon Dust filter alarm threshold',
'options': list([
'180',
'300',
'500',
'700',
]),
}),
'context': <ANY>,
'entity_id': 'select.clim_salon_dust_filter_alarm_threshold',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '500',
})
# ---
# name: test_all_entities[da_ac_rac_01001][select.aire_dormitorio_principal_dust_filter_alarm_threshold-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'180',
'300',
'500',
'700',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.aire_dormitorio_principal_dust_filter_alarm_threshold',
'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': 'Dust filter alarm threshold',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dust_filter_alarm',
'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_samsungce.dustFilterAlarm_alarmThreshold_alarmThreshold',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ac_rac_01001][select.aire_dormitorio_principal_dust_filter_alarm_threshold-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Aire Dormitorio Principal Dust filter alarm threshold',
'options': list([
'180',
'300',
'500',
'700',
]),
}),
'context': <ANY>,
'entity_id': 'select.aire_dormitorio_principal_dust_filter_alarm_threshold',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '500',
})
# ---
# name: test_all_entities[da_ks_microwave_0101x][select.microwave_lamp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -237,199 +115,6 @@
'state': 'high',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][select.kitchen_oven-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'stop',
'run',
'pause',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.kitchen_oven',
'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': None,
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'operating_state',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_washerOperatingState_machineState_machineState',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][select.kitchen_oven-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven',
'options': list([
'stop',
'run',
'pause',
]),
}),
'context': <ANY>,
'entity_id': 'select.kitchen_oven',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'stop',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][select.kitchen_oven_spin_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'rinse_hold',
'no_spin',
'400',
'800',
'1000',
'1200',
'1400',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.kitchen_oven_spin_level',
'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': 'Spin level',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'spin_level',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_custom.washerSpinLevel_washerSpinLevel_washerSpinLevel',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][select.kitchen_oven_spin_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven Spin level',
'options': list([
'rinse_hold',
'no_spin',
'400',
'800',
'1000',
'1200',
'1400',
]),
}),
'context': <ANY>,
'entity_id': 'select.kitchen_oven_spin_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1400',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][select.kitchen_oven_water_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'none',
'cold',
'20',
'30',
'40',
'60',
'90',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.kitchen_oven_water_temperature',
'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': 'Water temperature',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_temperature',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_custom.washerWaterTemperature_washerWaterTemperature_washerWaterTemperature',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][select.kitchen_oven_water_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven Water temperature',
'options': list([
'none',
'cold',
'20',
'30',
'40',
'60',
'90',
]),
}),
'context': <ANY>,
'entity_id': 'select.kitchen_oven_water_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'none',
})
# ---
# name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -603,65 +288,6 @@
'state': 'stop',
})
# ---
# name: test_all_entities[da_wm_dw_01011][select.dishwasher-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'stop',
'run',
'pause',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.dishwasher',
'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': None,
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'operating_state',
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_dishwasherOperatingState_machineState_machineState',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_dw_01011][select.dishwasher-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Dishwasher',
'options': list([
'stop',
'run',
'pause',
]),
}),
'context': <ANY>,
'entity_id': 'select.dishwasher',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'stop',
})
# ---
# name: test_all_entities[da_wm_sc_000001][select.airdresser-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

File diff suppressed because it is too large Load Diff

View File

@@ -191,102 +191,6 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][switch.kitchen_oven-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.kitchen_oven',
'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': None,
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_switch_switch_switch',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][switch.kitchen_oven-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven',
}),
'context': <ANY>,
'entity_id': 'switch.kitchen_oven',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_oven_0107x][switch.kitchen_oven_bubble_soak-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.kitchen_oven_bubble_soak',
'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': 'Bubble Soak',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bubble_soak',
'unique_id': '199d7863-ad04-793d-176d-658f10062575_main_samsungce.washerBubbleSoak_status_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_oven_0107x][switch.kitchen_oven_bubble_soak-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen oven Bubble Soak',
}),
'context': <ANY>,
'entity_id': 'switch.kitchen_oven_bubble_soak',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -189,34 +189,3 @@ async def test_availability_at_start(
"""Test unavailable at boot."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("select.dryer").state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"])
async def test_select_option_as_integer(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test selecting an option represented as an integer."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("select.clim_salon_dust_filter_alarm_threshold")
assert state.state == "500"
assert all(isinstance(option, str) for option in state.attributes[ATTR_OPTIONS])
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.clim_salon_dust_filter_alarm_threshold",
ATTR_OPTION: "300",
},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977",
Capability.SAMSUNG_CE_DUST_FILTER_ALARM,
Command.SET_ALARM_THRESHOLD,
MAIN,
argument=300,
)

View File

@@ -1,152 +0,0 @@
{
"endpoint": "https://apigw.tuyaus.com",
"mqtt_connected": true,
"disabled_by": null,
"disabled_polling": false,
"name": "Mini-Split",
"category": "kt",
"product_id": "wxqdp6ecfkd78zzz",
"product_name": "Air Conditioner",
"online": true,
"sub": false,
"time_zone": "-07:00",
"active_time": "2024-07-06T03:42:10+00:00",
"create_time": "2024-07-06T03:42:10+00:00",
"update_time": "2024-07-06T03:42:10+00:00",
"function": {
"switch": {
"type": "Boolean",
"value": {}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103/F",
"min": 160,
"max": 900,
"scale": 1,
"step": 10
}
},
"mode": {
"type": "Enum",
"value": {
"range": ["cold", "hot", "wet", "wind", "auto"]
}
},
"mode_eco": {
"type": "Boolean",
"value": {}
},
"heat": {
"type": "Boolean",
"value": {}
},
"light": {
"type": "Boolean",
"value": {}
},
"lock": {
"type": "Boolean",
"value": {}
},
"switch_horizontal": {
"type": "Boolean",
"value": {}
},
"sleep": {
"type": "Boolean",
"value": {}
},
"health": {
"type": "Boolean",
"value": {}
}
},
"status_range": {
"switch": {
"type": "Boolean",
"value": {}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103/F",
"min": 160,
"max": 900,
"scale": 1,
"step": 10
}
},
"temp_current": {
"type": "Integer",
"value": {
"unit": "\u2103/F",
"min": -300,
"max": 1760,
"scale": 1,
"step": 10
}
},
"mode": {
"type": "Enum",
"value": {
"range": ["cold", "hot", "wet", "wind", "auto"]
}
},
"mode_eco": {
"type": "Boolean",
"value": {}
},
"heat": {
"type": "Boolean",
"value": {}
},
"light": {
"type": "Boolean",
"value": {}
},
"lock": {
"type": "Boolean",
"value": {}
},
"power_consumption": {
"type": "Integer",
"value": {
"unit": "kW\u00b7h",
"min": 0,
"max": 255,
"scale": 0,
"step": 1
}
},
"switch_horizontal": {
"type": "Boolean",
"value": {}
},
"sleep": {
"type": "Boolean",
"value": {}
},
"health": {
"type": "Boolean",
"value": {}
}
},
"status": {
"switch": false,
"temp_set": 660,
"temp_current": 690,
"mode": "cold",
"mode_eco": false,
"heat": false,
"light": false,
"lock": false,
"power_consumption": 0,
"switch_horizontal": false,
"sleep": false,
"health": false
},
"set_up": true,
"support_local": true
}

View File

@@ -804,89 +804,6 @@
'state': 'cool',
})
# ---
# name: test_platform_setup_and_discovery[climate.mini_split-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 90.0,
'min_temp': 16.0,
'swing_modes': list([
'off',
'horizontal',
]),
'target_temp_step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.mini_split',
'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': None,
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 417>,
'translation_key': None,
'unique_id': 'tuya.zzz87dkfce6pdqxwtk',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[climate.mini_split-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 69.0,
'friendly_name': 'Mini-Split',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 90.0,
'min_temp': 16.0,
'supported_features': <ClimateEntityFeature: 417>,
'swing_mode': 'off',
'swing_modes': list([
'off',
'horizontal',
]),
'target_temp_step': 1.0,
'temperature': 66.0,
}),
'context': <ANY>,
'entity_id': 'climate.mini_split',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[climate.mr_pure-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1373,169 +1290,3 @@
'state': 'heat_cool',
})
# ---
# name: test_us_customary_system[climate.air_conditioner]
ReadOnlyDict({
'current_temperature': 72,
'max_temp': 187,
'min_temp': 61,
'target_temp_step': 1.0,
'temperature': 73,
})
# ---
# name: test_us_customary_system[climate.anbau]
ReadOnlyDict({
'max_temp': 95,
'min_temp': 41,
'target_temp_step': 1.0,
})
# ---
# name: test_us_customary_system[climate.bathroom_radiator]
ReadOnlyDict({
'current_temperature': 67,
'max_temp': 158,
'min_temp': 34,
'target_temp_step': 0.5,
'temperature': 54,
})
# ---
# name: test_us_customary_system[climate.boiler_temperature_controller]
ReadOnlyDict({
'current_temperature': 136,
'max_temp': 95,
'min_temp': 45,
'target_temp_step': 1.0,
})
# ---
# name: test_us_customary_system[climate.clima_cucina]
ReadOnlyDict({
'current_temperature': 81,
'max_temp': 95,
'min_temp': 41,
'target_temp_step': 1.0,
'temperature': 77,
})
# ---
# name: test_us_customary_system[climate.el_termostato_de_la_cocina]
ReadOnlyDict({
'current_temperature': 113,
'max_temp': 45,
'min_temp': 34,
'target_temp_step': 0.5,
'temperature': 40,
})
# ---
# name: test_us_customary_system[climate.empore]
ReadOnlyDict({
'current_temperature': 66,
'max_temp': 95,
'min_temp': 41,
'target_temp_step': 1.0,
'temperature': 95,
})
# ---
# name: test_us_customary_system[climate.floor_thermostat_kitchen]
ReadOnlyDict({
'current_temperature': 68,
'max_temp': 95,
'min_temp': 41,
'target_temp_step': 1.0,
'temperature': 36,
})
# ---
# name: test_us_customary_system[climate.geti_solar_pv_water_heater]
ReadOnlyDict({
'current_temperature': 140,
'max_temp': 95,
'min_temp': 45,
'target_temp_step': 1.0,
})
# ---
# name: test_us_customary_system[climate.kabinet]
ReadOnlyDict({
'current_temperature': 67,
'max_temp': 203,
'min_temp': 41,
'target_temp_step': 0.5,
'temperature': 71,
})
# ---
# name: test_us_customary_system[climate.master_bedroom_ac]
ReadOnlyDict({
'current_temperature': 79,
'max_temp': 190,
'min_temp': 61,
'target_temp_step': 0.5,
'temperature': 167,
})
# ---
# name: test_us_customary_system[climate.mini_split]
ReadOnlyDict({
'current_temperature': 156,
'max_temp': 194,
'min_temp': 61,
'target_temp_step': 1.0,
'temperature': 151,
})
# ---
# name: test_us_customary_system[climate.mr_pure]
ReadOnlyDict({
'current_temperature': None,
'max_temp': 95,
'min_temp': 45,
'target_temp_step': 1.0,
})
# ---
# name: test_us_customary_system[climate.polotentsosushitel]
ReadOnlyDict({
'current_temperature': 78,
'max_temp': 104,
'min_temp': 41,
'target_temp_step': 0.5,
'temperature': 41,
})
# ---
# name: test_us_customary_system[climate.salon]
ReadOnlyDict({
'current_temperature': 69,
'max_temp': 43,
'min_temp': 32,
'target_temp_step': 0.5,
'temperature': 43,
})
# ---
# name: test_us_customary_system[climate.smart_thermostats]
ReadOnlyDict({
'current_temperature': 71,
'max_temp': 194,
'min_temp': 41,
'target_temp_step': 1.0,
'temperature': 54,
})
# ---
# name: test_us_customary_system[climate.sove]
ReadOnlyDict({
'current_temperature': 75,
'max_temp': 187,
'min_temp': 61,
'target_temp_step': 1.0,
'temperature': 61,
})
# ---
# name: test_us_customary_system[climate.term_prizemi]
ReadOnlyDict({
'current_temperature': 73,
'max_temp': 158,
'min_temp': 33,
'target_temp_step': 0.1,
'temperature': 73,
})
# ---
# name: test_us_customary_system[climate.wifi_smart_gas_boiler_thermostat]
ReadOnlyDict({
'current_temperature': 77,
'max_temp': 95,
'min_temp': 41,
'target_temp_step': 0.5,
'temperature': 72,
})
# ---

View File

@@ -8462,34 +8462,3 @@
'via_device_id': None,
})
# ---
# name: test_device_registry[zzz87dkfce6pdqxwtk]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'zzz87dkfce6pdqxwtk',
),
}),
'labels': set({
}),
'manufacturer': 'Tuya',
'model': 'Air Conditioner',
'model_id': 'wxqdp6ecfkd78zzz',
'name': 'Mini-Split',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@@ -2399,63 +2399,6 @@
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[light.mini_split_backlight-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'light.mini_split_backlight',
'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': 'Backlight',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'backlight',
'unique_id': 'tuya.zzz87dkfce6pdqxwtklight',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[light.mini_split_backlight-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'color_mode': None,
'friendly_name': 'Mini-Split Backlight',
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.mini_split_backlight',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[light.parker_ceiling_fan_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -6627,54 +6627,6 @@
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.mini_split_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.mini_split_child_lock',
'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': 'Child lock',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'child_lock',
'unique_id': 'tuya.zzz87dkfce6pdqxwtklock',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.mini_split_child_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mini-Split Child lock',
}),
'context': <ANY>,
'entity_id': 'switch.mini_split_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.mirilla_puerta_flip-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -7,18 +7,13 @@ from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_HVAC_MODE,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_TARGET_TEMP_STEP,
ATTR_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
@@ -34,7 +29,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotSupported
from homeassistant.helpers import entity_registry as er
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from . import initialize_entry
@@ -56,34 +50,6 @@ async def test_platform_setup_and_discovery(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE])
async def test_us_customary_system(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_devices: list[CustomerDevice],
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test platform setup and discovery."""
hass.config.units = US_CUSTOMARY_SYSTEM
await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices)
for entity in entity_registry.entities.values():
state = hass.states.get(entity.entity_id)
assert state.attributes == snapshot(
name=entity.entity_id,
include=props(
ATTR_CURRENT_TEMPERATURE,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_TARGET_TEMP_STEP,
ATTR_TEMPERATURE,
),
)
@pytest.mark.parametrize(
("mock_device_code", "entity_id", "service", "service_data", "expected_commands"),
[

View File

@@ -1,44 +0,0 @@
# serializer version: 1
# name: test_diagnostics
dict({
'ABCDEF': dict({
'air_quality_index': 29,
'attributions': list([
dict({
'logo': 'Netherland-RIVM.png',
'name': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit',
'url': 'http://www.luchtmeetnet.nl/',
}),
dict({
'logo': None,
'name': 'World Air Quality Index Project',
'url': 'https://waqi.info/',
}),
]),
'city': dict({
'coordinates': dict({
'latitude': 52.105031,
'longitude': 5.124464,
}),
'external_url': 'https://aqicn.org/city/netherland/utrecht/de-jongweg',
'location': None,
'name': 'de Jongweg, Utrecht',
}),
'dominant_pollutant': 'o3',
'extended_air_quality': dict({
'carbon_monoxide': 2.3,
'humidity': 80,
'nephelometry': 80,
'nitrogen_dioxide': 2.3,
'ozone': 29.4,
'pm10': 12,
'pm25': 17,
'pressure': 1008.8,
'sulfur_dioxide': 2.3,
'temperature': 16,
}),
'measured_at': '2023-08-07T17:00:00+02:00',
'station_id': 4584,
}),
})
# ---

View File

@@ -1,28 +0,0 @@
"""Tests for the diagnostics data provided by the WAQI integration."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_waqi: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -3259,7 +3259,7 @@ async def test_extract_from_target(
) -> None:
"""Test extract_from_target command with mixed target types including entities, devices, areas, and labels."""
async def call_command(target: dict[str, list[str]]) -> Any:
async def call_command(target: dict[str, str]) -> Any:
await websocket_client.send_json_auto_id(
{"type": "extract_from_target", "target": target}
)

View File

@@ -1,313 +0,0 @@
"""Test area template functions."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from tests.common import MockConfigEntry
from tests.helpers.template.helpers import assert_result_info, render_to_info
async def test_areas(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None:
"""Test areas function."""
# Test no areas
info = render_to_info(hass, "{{ areas() }}")
assert_result_info(info, [])
assert info.rate_limit is None
# Test one area
area1 = area_registry.async_get_or_create("area1")
info = render_to_info(hass, "{{ areas() }}")
assert_result_info(info, [area1.id])
assert info.rate_limit is None
# Test multiple areas
area2 = area_registry.async_get_or_create("area2")
info = render_to_info(hass, "{{ areas() }}")
assert_result_info(info, [area1.id, area2.id])
assert info.rate_limit is None
async def test_area_id(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area_id function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing entity id
info = render_to_info(hass, "{{ area_id('sensor.fake') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing device id (hex value)
info = render_to_info(hass, "{{ area_id('123abc') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing area name
info = render_to_info(hass, "{{ area_id('fake area name') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_id(56) }}")
assert_result_info(info, None)
assert info.rate_limit is None
area_registry.async_get_or_create("sensor.fake")
# Test device with single entity, which has no area
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entity_entry = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
device_id=device_entry.id,
)
info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test device ID, entity ID and area name as input with area name that looks like
# a device ID. Try a filter too
area_entry_hex = area_registry.async_get_or_create("123abc")
device_entry = device_registry.async_update_device(
device_entry.id, area_id=area_entry_hex.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry_hex.id
)
info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_id }}}}")
assert_result_info(info, area_entry_hex.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry_hex.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{area_entry_hex.name}') }}}}")
assert_result_info(info, area_entry_hex.id)
assert info.rate_limit is None
# Test device ID, entity ID and area name as input with area name that looks like an
# entity ID
area_entry_entity_id = area_registry.async_get_or_create("sensor.fake")
device_entry = device_registry.async_update_device(
device_entry.id, area_id=area_entry_entity_id.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry_entity_id.id
)
info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{area_entry_entity_id.name}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
# Make sure that when entity doesn't have an area but its device does, that's what
# gets returned
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry_entity_id.id
)
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
async def test_area_name(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area_name function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing entity id
info = render_to_info(hass, "{{ area_name('sensor.fake') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing device id (hex value)
info = render_to_info(hass, "{{ area_name('123abc') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing area id
info = render_to_info(hass, "{{ area_name('1234567890') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_name(56) }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test device with single entity, which has no area
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entity_entry = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
device_id=device_entry.id,
)
info = render_to_info(hass, f"{{{{ area_name('{device_entry.id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test device ID, entity ID and area id as input. Try a filter too
area_entry = area_registry.async_get_or_create("123abc")
device_entry = device_registry.async_update_device(
device_entry.id, area_id=area_entry.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry.id
)
info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_name }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_name('{area_entry.id}') }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
# Make sure that when entity doesn't have an area but its device does, that's what
# gets returned
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=None
)
info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
async def test_area_entities(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area_entities function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing device id
info = render_to_info(hass, "{{ area_entities('deadbeef') }}")
assert_result_info(info, [])
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_entities(56) }}")
assert_result_info(info, [])
assert info.rate_limit is None
area_entry = area_registry.async_get_or_create("sensor.fake")
entity_entry = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
)
entity_registry.async_update_entity(entity_entry.entity_id, area_id=area_entry.id)
info = render_to_info(hass, f"{{{{ area_entities('{area_entry.id}') }}}}")
assert_result_info(info, ["light.hue_5678"])
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_entities }}}}")
assert_result_info(info, ["light.hue_5678"])
assert info.rate_limit is None
# Test for entities that inherit area from device
device_entry = device_registry.async_get_or_create(
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
config_entry_id=config_entry.entry_id,
suggested_area="sensor.fake",
)
entity_registry.async_get_or_create(
"light",
"hue_light",
"5678",
config_entry=config_entry,
device_id=device_entry.id,
)
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_entities }}}}")
assert_result_info(info, ["light.hue_5678", "light.hue_light_5678"])
assert info.rate_limit is None
async def test_area_devices(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test area_devices function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing device id
info = render_to_info(hass, "{{ area_devices('deadbeef') }}")
assert_result_info(info, [])
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_devices(56) }}")
assert_result_info(info, [])
assert info.rate_limit is None
area_entry = area_registry.async_get_or_create("sensor.fake")
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
suggested_area=area_entry.name,
)
info = render_to_info(hass, f"{{{{ area_devices('{area_entry.id}') }}}}")
assert_result_info(info, [device_entry.id])
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_devices }}}}")
assert_result_info(info, [device_entry.id])
assert info.rate_limit is None

View File

@@ -36,6 +36,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity,
entity_registry as er,
issue_registry as ir,
@@ -2634,6 +2636,306 @@ async def test_issue(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> N
assert info.rate_limit is None
async def test_areas(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None:
"""Test areas function."""
# Test no areas
info = render_to_info(hass, "{{ areas() }}")
assert_result_info(info, [])
assert info.rate_limit is None
# Test one area
area1 = area_registry.async_get_or_create("area1")
info = render_to_info(hass, "{{ areas() }}")
assert_result_info(info, [area1.id])
assert info.rate_limit is None
# Test multiple areas
area2 = area_registry.async_get_or_create("area2")
info = render_to_info(hass, "{{ areas() }}")
assert_result_info(info, [area1.id, area2.id])
assert info.rate_limit is None
async def test_area_id(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area_id function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing entity id
info = render_to_info(hass, "{{ area_id('sensor.fake') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing device id (hex value)
info = render_to_info(hass, "{{ area_id('123abc') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing area name
info = render_to_info(hass, "{{ area_id('fake area name') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_id(56) }}")
assert_result_info(info, None)
assert info.rate_limit is None
area_entry_entity_id = area_registry.async_get_or_create("sensor.fake")
# Test device with single entity, which has no area
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entity_entry = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
device_id=device_entry.id,
)
info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test device ID, entity ID and area name as input with area name that looks like
# a device ID. Try a filter too
area_entry_hex = area_registry.async_get_or_create("123abc")
device_entry = device_registry.async_update_device(
device_entry.id, area_id=area_entry_hex.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry_hex.id
)
info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_id }}}}")
assert_result_info(info, area_entry_hex.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry_hex.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{area_entry_hex.name}') }}}}")
assert_result_info(info, area_entry_hex.id)
assert info.rate_limit is None
# Test device ID, entity ID and area name as input with area name that looks like an
# entity ID
area_entry_entity_id = area_registry.async_get_or_create("sensor.fake")
device_entry = device_registry.async_update_device(
device_entry.id, area_id=area_entry_entity_id.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry_entity_id.id
)
info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_id('{area_entry_entity_id.name}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
# Make sure that when entity doesn't have an area but its device does, that's what
# gets returned
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry_entity_id.id
)
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry_entity_id.id)
assert info.rate_limit is None
async def test_area_name(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area_name function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing entity id
info = render_to_info(hass, "{{ area_name('sensor.fake') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing device id (hex value)
info = render_to_info(hass, "{{ area_name('123abc') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test non existing area id
info = render_to_info(hass, "{{ area_name('1234567890') }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_name(56) }}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test device with single entity, which has no area
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entity_entry = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
device_id=device_entry.id,
)
info = render_to_info(hass, f"{{{{ area_name('{device_entry.id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}")
assert_result_info(info, None)
assert info.rate_limit is None
# Test device ID, entity ID and area id as input. Try a filter too
area_entry = area_registry.async_get_or_create("123abc")
device_entry = device_registry.async_update_device(
device_entry.id, area_id=area_entry.id
)
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=area_entry.id
)
info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_name }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ area_name('{area_entry.id}') }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
# Make sure that when entity doesn't have an area but its device does, that's what
# gets returned
entity_entry = entity_registry.async_update_entity(
entity_entry.entity_id, area_id=None
)
info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}")
assert_result_info(info, area_entry.name)
assert info.rate_limit is None
async def test_area_entities(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area_entities function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing device id
info = render_to_info(hass, "{{ area_entities('deadbeef') }}")
assert_result_info(info, [])
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_entities(56) }}")
assert_result_info(info, [])
assert info.rate_limit is None
area_entry = area_registry.async_get_or_create("sensor.fake")
entity_entry = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
)
entity_registry.async_update_entity(entity_entry.entity_id, area_id=area_entry.id)
info = render_to_info(hass, f"{{{{ area_entities('{area_entry.id}') }}}}")
assert_result_info(info, ["light.hue_5678"])
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_entities }}}}")
assert_result_info(info, ["light.hue_5678"])
assert info.rate_limit is None
# Test for entities that inherit area from device
device_entry = device_registry.async_get_or_create(
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
config_entry_id=config_entry.entry_id,
suggested_area="sensor.fake",
)
entity_registry.async_get_or_create(
"light",
"hue_light",
"5678",
config_entry=config_entry,
device_id=device_entry.id,
)
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_entities }}}}")
assert_result_info(info, ["light.hue_5678", "light.hue_light_5678"])
assert info.rate_limit is None
async def test_area_devices(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test area_devices function."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
# Test non existing device id
info = render_to_info(hass, "{{ area_devices('deadbeef') }}")
assert_result_info(info, [])
assert info.rate_limit is None
# Test wrong value type
info = render_to_info(hass, "{{ area_devices(56) }}")
assert_result_info(info, [])
assert info.rate_limit is None
area_entry = area_registry.async_get_or_create("sensor.fake")
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
suggested_area=area_entry.name,
)
info = render_to_info(hass, f"{{{{ area_devices('{area_entry.id}') }}}}")
assert_result_info(info, [device_entry.id])
assert info.rate_limit is None
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_devices }}}}")
assert_result_info(info, [device_entry.id])
assert info.rate_limit is None
def test_closest_function_to_coord(hass: HomeAssistant) -> None:
"""Test closest function to coord."""
hass.states.async_set(