mirror of
https://github.com/home-assistant/core.git
synced 2025-11-24 02:07:01 +00:00
Compare commits
53 Commits
icon_title
...
setpoint_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725bd3d671 | ||
|
|
cfc4fa6342 | ||
|
|
32a40e5919 | ||
|
|
97de944a14 | ||
|
|
c9bd87f4b3 | ||
|
|
ac46568996 | ||
|
|
7c1b8ee02c | ||
|
|
aa6901265d | ||
|
|
b76e9ad1c0 | ||
|
|
edb8007c65 | ||
|
|
956a29411f | ||
|
|
1a2361050b | ||
|
|
0c9e92f6f9 | ||
|
|
bfdff46859 | ||
|
|
9a22808499 | ||
|
|
88b373af41 | ||
|
|
dea2f37e8f | ||
|
|
30cce68e0b | ||
|
|
985eff972a | ||
|
|
31ca332158 | ||
|
|
bf76c1601d | ||
|
|
e572f8d48f | ||
|
|
482b5d49a3 | ||
|
|
126fd217e7 | ||
|
|
0327b0e1ec | ||
|
|
3d5a7b4813 | ||
|
|
e0bb30f63b | ||
|
|
e5ae58c5df | ||
|
|
13e4bb4b93 | ||
|
|
d5fd27d2a2 | ||
|
|
0a034b9984 | ||
|
|
6a8106c0eb | ||
|
|
2cacfc7413 | ||
|
|
388ab5c16c | ||
|
|
81ea6f8c25 | ||
|
|
4f885994b7 | ||
|
|
25e2c9ee80 | ||
|
|
12c04f5571 | ||
|
|
3ad1c6a47a | ||
|
|
e7e13ecc74 | ||
|
|
991b8d2040 | ||
|
|
b650e71660 | ||
|
|
9ddf15e348 | ||
|
|
15082f9111 | ||
|
|
12f16611ff | ||
|
|
8041be3d08 | ||
|
|
40b021e755 | ||
|
|
aab57eda96 | ||
|
|
f0dd37caa5 | ||
|
|
662b178495 | ||
|
|
cb3d30884a | ||
|
|
49e6f20372 | ||
|
|
75d02661eb |
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
- arch: i386
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
@@ -463,7 +463,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -627,6 +627,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/guardian/ @bachya
|
||||
/homeassistant/components/habitica/ @tr4nt0r
|
||||
/tests/components/habitica/ @tr4nt0r
|
||||
/homeassistant/components/hanna/ @bestycame
|
||||
/tests/components/hanna/ @bestycame
|
||||
/homeassistant/components/hardkernel/ @home-assistant/core
|
||||
/tests/components/hardkernel/ @home-assistant/core
|
||||
/homeassistant/components/hardware/ @home-assistant/core
|
||||
@@ -846,6 +848,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/kraken/ @eifinger
|
||||
/homeassistant/components/kulersky/ @emlove
|
||||
/tests/components/kulersky/ @emlove
|
||||
/homeassistant/components/labs/ @home-assistant/core
|
||||
/tests/components/labs/ @home-assistant/core
|
||||
/homeassistant/components/lacrosse_view/ @IceBotYT
|
||||
/tests/components/lacrosse_view/ @IceBotYT
|
||||
/homeassistant/components/lamarzocco/ @zweckj
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -25,7 +25,7 @@ RUN \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
@@ -176,6 +176,8 @@ FRONTEND_INTEGRATIONS = {
|
||||
STAGE_0_INTEGRATIONS = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
||||
# Setup labs for preview features
|
||||
("labs", {"labs"}, STAGE_0_SUBSTAGE_TIMEOUT),
|
||||
# Setup frontend
|
||||
("frontend", FRONTEND_INTEGRATIONS, None),
|
||||
# Setup recorder
|
||||
@@ -212,6 +214,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"backup",
|
||||
"frontend",
|
||||
"hardware",
|
||||
"labs",
|
||||
"logger",
|
||||
"network",
|
||||
"system_health",
|
||||
|
||||
@@ -60,35 +60,6 @@ from .server import Server
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_FFMPEG = "ffmpeg"
|
||||
_SUPPORTED_STREAMS = frozenset(
|
||||
(
|
||||
"bubble",
|
||||
"dvrip",
|
||||
"expr",
|
||||
_FFMPEG,
|
||||
"gopro",
|
||||
"homekit",
|
||||
"http",
|
||||
"https",
|
||||
"httpx",
|
||||
"isapi",
|
||||
"ivideon",
|
||||
"kasa",
|
||||
"nest",
|
||||
"onvif",
|
||||
"roborock",
|
||||
"rtmp",
|
||||
"rtmps",
|
||||
"rtmpx",
|
||||
"rtsp",
|
||||
"rtsps",
|
||||
"rtspx",
|
||||
"tapo",
|
||||
"tcp",
|
||||
"webrtc",
|
||||
"webtorrent",
|
||||
)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -197,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
|
||||
return False
|
||||
|
||||
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
||||
await provider.initialize()
|
||||
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
||||
return True
|
||||
|
||||
@@ -228,16 +200,21 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
self._session = session
|
||||
self._rest_client = rest_client
|
||||
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||
self._supported_schemes: set[str] = set()
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
"""Return the integration domain of the provider."""
|
||||
return DOMAIN
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the provider."""
|
||||
self._supported_schemes = await self._rest_client.schemes.list()
|
||||
|
||||
@callback
|
||||
def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Return if this provider is supports the Camera as source."""
|
||||
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
|
||||
return stream_source.partition(":")[0] in self._supported_schemes
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self,
|
||||
|
||||
@@ -6,4 +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}/"
|
||||
RECOMMENDED_VERSION = "1.9.11"
|
||||
RECOMMENDED_VERSION = "1.9.12"
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["go2rtc-client==0.2.1"],
|
||||
"requirements": ["go2rtc-client==0.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -29,8 +29,18 @@ _RESPAWN_COOLDOWN = 1
|
||||
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
||||
# Do not edit it manually
|
||||
|
||||
app:
|
||||
modules: {app_modules}
|
||||
|
||||
api:
|
||||
listen: "{api_ip}:{api_port}"
|
||||
allow_paths: {api_allow_paths}
|
||||
|
||||
# ffmpeg needs the exec module
|
||||
# Restrict execution to only ffmpeg binary
|
||||
exec:
|
||||
allow_paths:
|
||||
- ffmpeg
|
||||
|
||||
rtsp:
|
||||
listen: "127.0.0.1:18554"
|
||||
@@ -40,6 +50,43 @@ webrtc:
|
||||
ice_servers: []
|
||||
"""
|
||||
|
||||
_APP_MODULES = (
|
||||
"api",
|
||||
"exec", # Execution module for ffmpeg
|
||||
"ffmpeg",
|
||||
"http",
|
||||
"mjpeg",
|
||||
"onvif",
|
||||
"rtmp",
|
||||
"rtsp",
|
||||
"srtp",
|
||||
"webrtc",
|
||||
"ws",
|
||||
)
|
||||
|
||||
_API_ALLOW_PATHS = (
|
||||
"/", # UI static page and version control
|
||||
"/api", # Main API path
|
||||
"/api/frame.jpeg", # Snapshot functionality
|
||||
"/api/schemes", # Supported stream schemes
|
||||
"/api/streams", # Stream management
|
||||
"/api/webrtc", # Webrtc functionality
|
||||
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
|
||||
)
|
||||
|
||||
# Additional modules when UI is enabled
|
||||
_UI_APP_MODULES = (
|
||||
*_APP_MODULES,
|
||||
"debug",
|
||||
)
|
||||
# Additional api paths when UI is enabled
|
||||
_UI_API_ALLOW_PATHS = (
|
||||
*_API_ALLOW_PATHS,
|
||||
"/api/config", # UI config view
|
||||
"/api/log", # UI log view
|
||||
"/api/streams.dot", # UI network view
|
||||
)
|
||||
|
||||
_LOG_LEVEL_MAP = {
|
||||
"TRC": logging.DEBUG,
|
||||
"DBG": logging.DEBUG,
|
||||
@@ -61,14 +108,34 @@ class Go2RTCWatchdogError(HomeAssistantError):
|
||||
"""Raised on watchdog error."""
|
||||
|
||||
|
||||
def _create_temp_file(api_ip: str) -> str:
|
||||
def _format_list_for_yaml(items: tuple[str, ...]) -> str:
|
||||
"""Format a list of strings for yaml config."""
|
||||
if not items:
|
||||
return "[]"
|
||||
formatted_items = ",".join(f'"{item}"' for item in items)
|
||||
return f"[{formatted_items}]"
|
||||
|
||||
|
||||
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
|
||||
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(
|
||||
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
|
||||
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()
|
||||
)
|
||||
return file.name
|
||||
@@ -86,10 +153,7 @@ class Server:
|
||||
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._startup_complete = asyncio.Event()
|
||||
self._api_ip = _LOCALHOST_IP
|
||||
if enable_ui:
|
||||
# Listen on all interfaces for allowing access from all ips
|
||||
self._api_ip = ""
|
||||
self._enable_ui = enable_ui
|
||||
self._watchdog_task: asyncio.Task | None = None
|
||||
self._watchdog_tasks: list[asyncio.Task] = []
|
||||
|
||||
@@ -104,7 +168,7 @@ class Server:
|
||||
"""Start the server."""
|
||||
_LOGGER.debug("Starting go2rtc server")
|
||||
config_file = await self._hass.async_add_executor_job(
|
||||
_create_temp_file, self._api_ip
|
||||
_create_temp_file, self._enable_ui
|
||||
)
|
||||
|
||||
self._startup_complete.clear()
|
||||
|
||||
54
homeassistant/components/hanna/__init__.py
Normal file
54
homeassistant/components/hanna/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""The Hanna Instruments integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hanna_cloud import HannaCloudClient
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HannaConfigEntry, HannaDataCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
def _authenticate_and_get_devices(
|
||||
api_client: HannaCloudClient,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Authenticate and get devices in a single executor job."""
|
||||
api_client.authenticate(email, password)
|
||||
return api_client.get_devices()
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool:
|
||||
"""Set up Hanna Instruments from a config entry."""
|
||||
api_client = HannaCloudClient()
|
||||
devices = await hass.async_add_executor_job(
|
||||
_authenticate_and_get_devices,
|
||||
api_client,
|
||||
entry.data[CONF_EMAIL],
|
||||
entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
# Create device coordinators
|
||||
device_coordinators = {}
|
||||
for device in devices:
|
||||
coordinator = HannaDataCoordinator(hass, entry, device, api_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
device_coordinators[coordinator.device_identifier] = coordinator
|
||||
|
||||
# Set runtime data
|
||||
entry.runtime_data = device_coordinators
|
||||
|
||||
# Forward the setup to the platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
62
homeassistant/components/hanna/config_flow.py
Normal file
62
homeassistant/components/hanna/config_flow.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Config flow for Hanna Instruments integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hanna_cloud import AuthenticationError, HannaCloudClient
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HannaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Hanna Instruments."""
|
||||
|
||||
VERSION = 1
|
||||
data_schema = vol.Schema(
|
||||
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the setup flow."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_EMAIL])
|
||||
self._abort_if_unique_id_configured()
|
||||
client = HannaCloudClient()
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
client.authenticate,
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
except (Timeout, RequestsConnectionError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
self.data_schema, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
3
homeassistant/components/hanna/const.py
Normal file
3
homeassistant/components/hanna/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Hanna integration."""
|
||||
|
||||
DOMAIN = "hanna"
|
||||
72
homeassistant/components/hanna/coordinator.py
Normal file
72
homeassistant/components/hanna/coordinator.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Hanna Instruments data coordinator for Home Assistant.
|
||||
|
||||
This module provides the data coordinator for fetching and managing Hanna Instruments
|
||||
sensor data.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hanna_cloud import HannaCloudClient
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type HannaConfigEntry = ConfigEntry[dict[str, HannaDataCoordinator]]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HannaDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for fetching Hanna sensor data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HannaConfigEntry,
|
||||
device: dict[str, Any],
|
||||
api_client: HannaCloudClient,
|
||||
) -> None:
|
||||
"""Initialize the Hanna data coordinator."""
|
||||
self.api_client = api_client
|
||||
self.device_data = device
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_{self.device_identifier}",
|
||||
config_entry=config_entry,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
@property
|
||||
def device_identifier(self) -> str:
|
||||
"""Return the device identifier."""
|
||||
return self.device_data["DID"]
|
||||
|
||||
def get_parameters(self) -> list[dict[str, Any]]:
|
||||
"""Get all parameters from the sensor data."""
|
||||
return self.api_client.parameters
|
||||
|
||||
def get_parameter_value(self, key: str) -> Any:
|
||||
"""Get the value for a specific parameter."""
|
||||
for parameter in self.get_parameters():
|
||||
if parameter["name"] == key:
|
||||
return parameter["value"]
|
||||
return None
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch latest sensor data from the Hanna API."""
|
||||
try:
|
||||
readings = await self.hass.async_add_executor_job(
|
||||
self.api_client.get_last_device_reading, self.device_identifier
|
||||
)
|
||||
except RequestException as e:
|
||||
raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e
|
||||
except (KeyError, IndexError) as e:
|
||||
raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e
|
||||
return readings
|
||||
28
homeassistant/components/hanna/entity.py
Normal file
28
homeassistant/components/hanna/entity.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Hanna Instruments entity base class for Home Assistant.
|
||||
|
||||
This module provides the base entity class for Hanna Instruments entities.
|
||||
"""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HannaDataCoordinator
|
||||
|
||||
|
||||
class HannaEntity(CoordinatorEntity[HannaDataCoordinator]):
|
||||
"""Base class for Hanna entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: HannaDataCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.device_identifier)},
|
||||
manufacturer=coordinator.device_data.get("manufacturer"),
|
||||
model=coordinator.device_data.get("DM"),
|
||||
name=coordinator.device_data.get("name"),
|
||||
serial_number=coordinator.device_data.get("serial_number"),
|
||||
sw_version=coordinator.device_data.get("sw_version"),
|
||||
)
|
||||
10
homeassistant/components/hanna/manifest.json
Normal file
10
homeassistant/components/hanna/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "hanna",
|
||||
"name": "Hanna",
|
||||
"codeowners": ["@bestycame"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hanna",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hanna-cloud==0.0.6"]
|
||||
}
|
||||
70
homeassistant/components/hanna/quality_scale.yaml
Normal file
70
homeassistant/components/hanna/quality_scale.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't add actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
106
homeassistant/components/hanna/sensor.py
Normal file
106
homeassistant/components/hanna/sensor.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Hanna Instruments sensor integration for Home Assistant.
|
||||
|
||||
This module provides sensor entities for various Hanna Instruments devices,
|
||||
including pH, ORP, temperature, and chemical sensors. It uses the Hanna API
|
||||
to fetch readings and updates them periodically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import HannaConfigEntry, HannaDataCoordinator
|
||||
from .entity import HannaEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_DESCRIPTIONS = [
|
||||
SensorEntityDescription(
|
||||
key="ph",
|
||||
translation_key="ph_value",
|
||||
device_class=SensorDeviceClass.PH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="orp",
|
||||
translation_key="chlorine_orp_value",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temp",
|
||||
translation_key="water_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="airTemp",
|
||||
translation_key="air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="acidBase",
|
||||
translation_key="ph_acid_base_flow_rate",
|
||||
icon="mdi:chemical-weapon",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="cl",
|
||||
translation_key="chlorine_flow_rate",
|
||||
icon="mdi:chemical-weapon",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HannaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Hanna sensors from a config entry."""
|
||||
device_coordinators = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HannaSensor(coordinator, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
for coordinator in device_coordinators.values()
|
||||
)
|
||||
|
||||
|
||||
class HannaSensor(HannaEntity, SensorEntity):
|
||||
"""Representation of a Hanna sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HannaDataCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Hanna sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.coordinator.get_parameter_value(self.entity_description.key)
|
||||
44
homeassistant/components/hanna/strings.json
Normal file
44
homeassistant/components/hanna/strings.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Email address for your Hanna Cloud account",
|
||||
"password": "Password for your Hanna Cloud account"
|
||||
},
|
||||
"description": "Enter your Hanna Cloud credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_temperature": {
|
||||
"name": "Air temperature"
|
||||
},
|
||||
"chlorine_flow_rate": {
|
||||
"name": "Chlorine flow rate"
|
||||
},
|
||||
"chlorine_orp_value": {
|
||||
"name": "Chlorine ORP value"
|
||||
},
|
||||
"ph_acid_base_flow_rate": {
|
||||
"name": "pH Acid/Base flow rate"
|
||||
},
|
||||
"water_temperature": {
|
||||
"name": "Water temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ from random import random
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EVENT_LABS_UPDATED,
|
||||
EventLabsUpdatedData,
|
||||
async_is_preview_feature_enabled,
|
||||
)
|
||||
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
@@ -30,10 +35,14 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import (
|
||||
@@ -110,6 +119,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Notify backup listeners
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
|
||||
# Subscribe to labs feature updates for kitchen_sink preview repair
|
||||
@callback
|
||||
def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == "kitchen_sink"
|
||||
and event.data["preview_feature"] == "special_repair"
|
||||
):
|
||||
_async_update_special_repair(hass)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated)
|
||||
)
|
||||
|
||||
# Check if lab feature is currently enabled and create repair if so
|
||||
_async_update_special_repair(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -137,6 +163,27 @@ async def async_remove_config_entry_device(
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def _async_update_special_repair(hass: HomeAssistant) -> None:
|
||||
"""Create or delete the special repair issue.
|
||||
|
||||
Creates a repair issue when the special_repair lab feature is enabled,
|
||||
and deletes it when disabled. This demonstrates how lab features can interact
|
||||
with Home Assistant's repair system.
|
||||
"""
|
||||
if async_is_preview_feature_enabled(hass, DOMAIN, "special_repair"):
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"kitchen_sink_special_repair_issue",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="special_repair",
|
||||
)
|
||||
else:
|
||||
async_delete_issue(hass, DOMAIN, "kitchen_sink_special_repair_issue")
|
||||
|
||||
|
||||
async def _notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/kitchen_sink",
|
||||
"iot_class": "calculated",
|
||||
"preview_features": {
|
||||
"special_repair": {
|
||||
"feedback_url": "https://community.home-assistant.io",
|
||||
"learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"title": "The blinker fluid is empty and needs to be refilled"
|
||||
},
|
||||
"special_repair": {
|
||||
"description": "This is a special repair created by a preview feature! This demonstrates how lab features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.",
|
||||
"title": "Special repair feature preview"
|
||||
},
|
||||
"transmogrifier_deprecated": {
|
||||
"description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API",
|
||||
"title": "The transmogrifier component is deprecated"
|
||||
@@ -103,6 +107,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"special_repair": {
|
||||
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.",
|
||||
"disable_confirmation": "This will remove the special repair issue. Don't worry, this is just a demonstration feature.",
|
||||
"enable_confirmation": "This will create a special repair issue to demonstrate Labs preview features. This is just an example and won't affect your actual system.",
|
||||
"name": "Special repair"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"test_service_1": {
|
||||
"description": "Fake action for testing",
|
||||
|
||||
310
homeassistant/components/labs/__init__.py
Normal file
310
homeassistant/components/labs/__init__.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""The Home Assistant Labs integration.
|
||||
|
||||
This integration provides preview features that can be toggled on/off by users.
|
||||
Integrations can register lab preview features in their manifest.json which will appear
|
||||
in the Home Assistant Labs UI for users to enable or disable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.backup import async_get_manager
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_LABS_UPDATED,
|
||||
LABS_DATA,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
EventLabsUpdatedData,
|
||||
LabPreviewFeature,
|
||||
LabsData,
|
||||
LabsStoreData,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
__all__ = [
|
||||
"EVENT_LABS_UPDATED",
|
||||
"EventLabsUpdatedData",
|
||||
"async_is_preview_feature_enabled",
|
||||
]
|
||||
|
||||
|
||||
class LabsStorage(Store[LabsStoreData]):
|
||||
"""Custom Store for Labs that converts between runtime and storage formats.
|
||||
|
||||
Runtime format: {"preview_feature_status": {(domain, preview_feature)}}
|
||||
Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]}
|
||||
|
||||
Only enabled features are saved to storage - if stored, it's enabled.
|
||||
"""
|
||||
|
||||
async def _async_load_data(self) -> LabsStoreData | None:
|
||||
"""Load data and convert from storage format to runtime format."""
|
||||
raw_data = await super()._async_load_data()
|
||||
if raw_data is None:
|
||||
return None
|
||||
|
||||
status_list = raw_data.get("preview_feature_status", [])
|
||||
|
||||
# Convert list of objects to runtime set - if stored, it's enabled
|
||||
return {
|
||||
"preview_feature_status": {
|
||||
(item["domain"], item["preview_feature"]) for item in status_list
|
||||
}
|
||||
}
|
||||
|
||||
def _write_data(self, path: str, data: dict) -> None:
|
||||
"""Convert from runtime format to storage format and write.
|
||||
|
||||
Only saves enabled features - disabled is the default.
|
||||
"""
|
||||
# Extract the actual data (has version/key wrapper)
|
||||
actual_data = data.get("data", data)
|
||||
|
||||
# Check if this is Labs data (has preview_feature_status key)
|
||||
if "preview_feature_status" not in actual_data:
|
||||
# Not Labs data, write as-is
|
||||
super()._write_data(path, data)
|
||||
return
|
||||
|
||||
preview_status = actual_data["preview_feature_status"]
|
||||
|
||||
# Convert from runtime format (set of tuples) to storage format (list of dicts)
|
||||
status_list = [
|
||||
{"domain": domain, "preview_feature": preview_feature}
|
||||
for domain, preview_feature in preview_status
|
||||
]
|
||||
|
||||
# Build the final data structure with converted format
|
||||
data_copy = data.copy()
|
||||
data_copy["data"] = {"preview_feature_status": status_list}
|
||||
|
||||
super()._write_data(path, data_copy)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Labs component."""
|
||||
store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
|
||||
data = await store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {"preview_feature_status": set()}
|
||||
|
||||
# Scan ALL integrations for lab preview features (loaded or not)
|
||||
lab_preview_features = await _async_scan_all_preview_features(hass)
|
||||
|
||||
# Clean up preview features that no longer exist
|
||||
if lab_preview_features:
|
||||
valid_keys = {
|
||||
(pf.domain, pf.preview_feature) for pf in lab_preview_features.values()
|
||||
}
|
||||
stale_keys = data["preview_feature_status"] - valid_keys
|
||||
|
||||
if stale_keys:
|
||||
_LOGGER.debug(
|
||||
"Removing %d stale preview features: %s",
|
||||
len(stale_keys),
|
||||
stale_keys,
|
||||
)
|
||||
data["preview_feature_status"] -= stale_keys
|
||||
|
||||
await store.async_save(data)
|
||||
|
||||
hass.data[LABS_DATA] = LabsData(
|
||||
store=store,
|
||||
data=data,
|
||||
preview_features=lab_preview_features,
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_list_preview_features)
|
||||
websocket_api.async_register_command(hass, websocket_update_preview_feature)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _populate_preview_features(
|
||||
preview_features: dict[str, LabPreviewFeature],
|
||||
domain: str,
|
||||
labs_preview_features: dict[str, dict[str, str]],
|
||||
is_built_in: bool = True,
|
||||
) -> None:
|
||||
"""Populate preview features dictionary from integration preview_features.
|
||||
|
||||
Args:
|
||||
preview_features: Dictionary to populate
|
||||
domain: Integration domain
|
||||
labs_preview_features: Dictionary of preview feature definitions from manifest
|
||||
is_built_in: Whether this is a built-in integration
|
||||
"""
|
||||
for preview_feature_key, preview_feature_data in labs_preview_features.items():
|
||||
preview_feature = LabPreviewFeature(
|
||||
domain=domain,
|
||||
preview_feature=preview_feature_key,
|
||||
is_built_in=is_built_in,
|
||||
feedback_url=preview_feature_data.get("feedback_url"),
|
||||
learn_more_url=preview_feature_data.get("learn_more_url"),
|
||||
report_issue_url=preview_feature_data.get("report_issue_url"),
|
||||
)
|
||||
preview_features[preview_feature.full_key] = preview_feature
|
||||
|
||||
|
||||
async def _async_scan_all_preview_features(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, LabPreviewFeature]:
|
||||
"""Scan ALL available integrations for lab preview features (loaded or not)."""
|
||||
preview_features: dict[str, LabPreviewFeature] = {}
|
||||
|
||||
# Load pre-generated built-in lab preview features (already includes all data)
|
||||
for domain, domain_preview_features in LABS_PREVIEW_FEATURES.items():
|
||||
_populate_preview_features(
|
||||
preview_features, domain, domain_preview_features, is_built_in=True
|
||||
)
|
||||
|
||||
# Scan custom components
|
||||
custom_integrations = await async_get_custom_components(hass)
|
||||
_LOGGER.debug(
|
||||
"Loaded %d built-in + scanning %d custom integrations for lab preview features",
|
||||
len(preview_features),
|
||||
len(custom_integrations),
|
||||
)
|
||||
|
||||
for integration in custom_integrations.values():
|
||||
if labs_preview_features := integration.preview_features:
|
||||
_populate_preview_features(
|
||||
preview_features,
|
||||
integration.domain,
|
||||
labs_preview_features,
|
||||
is_built_in=False,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Loaded %d total lab preview features", len(preview_features))
|
||||
return preview_features
|
||||
|
||||
|
||||
@callback
|
||||
def async_is_preview_feature_enabled(
|
||||
hass: HomeAssistant, domain: str, preview_feature: str
|
||||
) -> bool:
|
||||
"""Check if a lab preview feature is enabled.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
|
||||
Returns:
|
||||
True if the preview feature is enabled, False otherwise
|
||||
"""
|
||||
if LABS_DATA not in hass.data:
|
||||
return False
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
|
||||
def websocket_list_preview_features(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List all lab preview features filtered by loaded integrations."""
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
loaded_components = hass.config.components
|
||||
|
||||
preview_features: list[dict[str, Any]] = [
|
||||
preview_feature.to_dict(
|
||||
(preview_feature.domain, preview_feature.preview_feature)
|
||||
in labs_data.data["preview_feature_status"]
|
||||
)
|
||||
for preview_feature_key, preview_feature in labs_data.preview_features.items()
|
||||
if preview_feature.domain in loaded_components
|
||||
]
|
||||
|
||||
connection.send_result(msg["id"], {"features": preview_features})
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "labs/update",
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("preview_feature"): str,
|
||||
vol.Required("enabled"): bool,
|
||||
vol.Optional("create_backup", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_preview_feature(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update a lab preview feature state."""
|
||||
domain = msg["domain"]
|
||||
preview_feature = msg["preview_feature"]
|
||||
enabled = msg["enabled"]
|
||||
create_backup = msg["create_backup"]
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
|
||||
# Build preview_feature_id for lookup
|
||||
preview_feature_id = f"{domain}.{preview_feature}"
|
||||
|
||||
# Validate preview feature exists
|
||||
if preview_feature_id not in labs_data.preview_features:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_FOUND,
|
||||
f"Preview feature {preview_feature_id} not found",
|
||||
)
|
||||
return
|
||||
|
||||
# Create backup if requested and enabling
|
||||
if create_backup and enabled:
|
||||
try:
|
||||
backup_manager = async_get_manager(hass)
|
||||
await backup_manager.async_create_automatic_backup()
|
||||
except Exception as err: # noqa: BLE001 - websocket handlers can catch broad exceptions
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_UNKNOWN_ERROR,
|
||||
f"Error creating backup: {err}",
|
||||
)
|
||||
return
|
||||
|
||||
# Update storage (only store enabled features, remove if disabled)
|
||||
if enabled:
|
||||
labs_data.data["preview_feature_status"].add((domain, preview_feature))
|
||||
else:
|
||||
labs_data.data["preview_feature_status"].discard((domain, preview_feature))
|
||||
|
||||
# Save changes immediately
|
||||
await labs_data.store.async_save(labs_data.data)
|
||||
|
||||
# Fire event
|
||||
event_data: EventLabsUpdatedData = {
|
||||
"domain": domain,
|
||||
"preview_feature": preview_feature,
|
||||
"enabled": enabled,
|
||||
}
|
||||
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
77
homeassistant/components/labs/const.py
Normal file
77
homeassistant/components/labs/const.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Constants for the Home Assistant Labs integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
DOMAIN = "labs"
|
||||
|
||||
STORAGE_KEY = "core.labs"
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
EVENT_LABS_UPDATED = "labs_updated"
|
||||
|
||||
|
||||
class EventLabsUpdatedData(TypedDict):
|
||||
"""Event data for labs_updated event."""
|
||||
|
||||
domain: str
|
||||
preview_feature: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class LabPreviewFeature:
|
||||
"""Lab preview feature definition."""
|
||||
|
||||
domain: str
|
||||
preview_feature: str
|
||||
is_built_in: bool = True
|
||||
feedback_url: str | None = None
|
||||
learn_more_url: str | None = None
|
||||
report_issue_url: str | None = None
|
||||
|
||||
@property
|
||||
def full_key(self) -> str:
|
||||
"""Return the full key for the preview feature (domain.preview_feature)."""
|
||||
return f"{self.domain}.{self.preview_feature}"
|
||||
|
||||
def to_dict(self, enabled: bool) -> dict[str, str | bool | None]:
|
||||
"""Return a serialized version of the preview feature.
|
||||
|
||||
Args:
|
||||
enabled: Whether the preview feature is currently enabled
|
||||
|
||||
Returns:
|
||||
Dictionary with preview feature data including enabled status
|
||||
"""
|
||||
return {
|
||||
"preview_feature": self.preview_feature,
|
||||
"domain": self.domain,
|
||||
"enabled": enabled,
|
||||
"is_built_in": self.is_built_in,
|
||||
"feedback_url": self.feedback_url,
|
||||
"learn_more_url": self.learn_more_url,
|
||||
"report_issue_url": self.report_issue_url,
|
||||
}
|
||||
|
||||
|
||||
type LabsStoreData = dict[str, set[tuple[str, str]]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabsData:
|
||||
"""Storage class for Labs global data."""
|
||||
|
||||
store: Store[LabsStoreData]
|
||||
data: LabsStoreData
|
||||
preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict)
|
||||
|
||||
|
||||
LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN)
|
||||
9
homeassistant/components/labs/manifest.json
Normal file
9
homeassistant/components/labs/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "labs",
|
||||
"name": "Home Assistant Labs",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/labs",
|
||||
"integration_type": "system",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
3
homeassistant/components/labs/strings.json
Normal file
3
homeassistant/components/labs/strings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Home Assistant Labs"
|
||||
}
|
||||
@@ -58,7 +58,7 @@ DISCOVERY_SCHEMAS = [
|
||||
platform=Platform.BUTTON,
|
||||
entity_description=MatterButtonEntityDescription(
|
||||
key="IdentifyButton",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=ButtonDeviceClass.IDENTIFY,
|
||||
command=lambda: clusters.Identify.Commands.Identify(identifyTime=15),
|
||||
),
|
||||
|
||||
@@ -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,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,8 +36,8 @@ from .const import (
|
||||
COFFEE_SYSTEM_PROFILE,
|
||||
DISABLED_TEMP_ENTITIES,
|
||||
DOMAIN,
|
||||
PROGRAM_IDS,
|
||||
PROGRAM_PHASE,
|
||||
STATE_PROGRAM_ID,
|
||||
STATE_STATUS_TAGS,
|
||||
MieleAppliance,
|
||||
PlatePowerStep,
|
||||
@@ -979,21 +979,16 @@ class MieleProgramIdSensor(MieleSensor):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
ret_val = STATE_PROGRAM_ID.get(self.device.device_type, {}).get(
|
||||
self.device.state_program_id
|
||||
return (
|
||||
PROGRAM_IDS[self.device.device_type](self.device.state_program_id).name
|
||||
if self.device.device_type in PROGRAM_IDS
|
||||
else None
|
||||
)
|
||||
if ret_val is None:
|
||||
_LOGGER.debug(
|
||||
"Unknown program id: %s on device type: %s",
|
||||
self.device.state_program_id,
|
||||
self.device.device_type,
|
||||
)
|
||||
return ret_val
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return the options list for the actual device type."""
|
||||
return sorted(set(STATE_PROGRAM_ID.get(self.device.device_type, {}).values()))
|
||||
return sorted(PROGRAM_IDS.get(self.device.device_type, {}).keys())
|
||||
|
||||
|
||||
class MieleTimeSensor(MieleRestorableSensor):
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"custom_program_9": "Custom program 9",
|
||||
"dark_garments": "Dark garments",
|
||||
"dark_mixed_grain_bread": "Dark mixed grain bread",
|
||||
"decrystallise_honey": "Decrystallise honey",
|
||||
"decrystallise_honey": "Decrystallize honey",
|
||||
"defrost": "Defrost",
|
||||
"defrosting_with_microwave": "Defrosting with microwave",
|
||||
"defrosting_with_steam": "Defrosting with steam",
|
||||
|
||||
@@ -239,6 +239,7 @@ from .const import (
|
||||
CONF_OSCILLATION_COMMAND_TOPIC,
|
||||
CONF_OSCILLATION_STATE_TOPIC,
|
||||
CONF_OSCILLATION_VALUE_TEMPLATE,
|
||||
CONF_PATTERN,
|
||||
CONF_PAYLOAD_ARM_AWAY,
|
||||
CONF_PAYLOAD_ARM_CUSTOM_BYPASS,
|
||||
CONF_PAYLOAD_ARM_HOME,
|
||||
@@ -465,6 +466,7 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
Platform.TEXT,
|
||||
]
|
||||
|
||||
_CODE_VALIDATION_MODE = {
|
||||
@@ -819,6 +821,16 @@ TEMPERATURE_UNIT_SELECTOR = SelectSelector(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
TEXT_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[TextSelectorType.TEXT.value, TextSelectorType.PASSWORD.value],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="text_mode",
|
||||
)
|
||||
)
|
||||
TEXT_SIZE_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(min=0, max=255, step=1, mode=NumberSelectorMode.BOX)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -1151,6 +1163,22 @@ def validate_sensor_platform_config(
|
||||
return errors
|
||||
|
||||
|
||||
@callback
|
||||
def validate_text_platform_config(
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""Validate the text entity options."""
|
||||
errors: dict[str, str] = {}
|
||||
if (
|
||||
CONF_MIN in config
|
||||
and CONF_MAX in config
|
||||
and config[CONF_MIN] > config[CONF_MAX]
|
||||
):
|
||||
errors["text_advanced_settings"] = "max_below_min"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
ENTITY_CONFIG_VALIDATOR: dict[
|
||||
str,
|
||||
Callable[[dict[str, Any]], dict[str, str]] | None,
|
||||
@@ -1170,6 +1198,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
Platform.SENSOR: validate_sensor_platform_config,
|
||||
Platform.SIREN: None,
|
||||
Platform.SWITCH: None,
|
||||
Platform.TEXT: validate_text_platform_config,
|
||||
}
|
||||
|
||||
|
||||
@@ -1430,6 +1459,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False
|
||||
),
|
||||
},
|
||||
Platform.TEXT: {},
|
||||
}
|
||||
PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
Platform.ALARM_CONTROL_PANEL: {
|
||||
@@ -3298,6 +3328,58 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
Platform.TEXT: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
),
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
CONF_MIN: PlatformField(
|
||||
selector=TEXT_SIZE_SELECTOR,
|
||||
required=True,
|
||||
default=0,
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
CONF_MAX: PlatformField(
|
||||
selector=TEXT_SIZE_SELECTOR,
|
||||
required=True,
|
||||
default=255,
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
CONF_MODE: PlatformField(
|
||||
selector=TEXT_MODE_SELECTOR,
|
||||
required=True,
|
||||
default=TextSelectorType.TEXT.value,
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
CONF_PATTERN: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.is_regex),
|
||||
error="invalid_regular_expression",
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
},
|
||||
}
|
||||
MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
|
||||
|
||||
@@ -138,6 +138,7 @@ CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic"
|
||||
CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template"
|
||||
CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic"
|
||||
CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template"
|
||||
CONF_PATTERN = "pattern"
|
||||
CONF_PAYLOAD_ARM_AWAY = "payload_arm_away"
|
||||
CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass"
|
||||
CONF_PAYLOAD_ARM_HOME = "payload_arm_home"
|
||||
|
||||
@@ -970,6 +970,21 @@
|
||||
"temperature_state_topic": "The MQTT topic to subscribe for changes of the target temperature. [Learn more.]({url}#temperature_state_topic)"
|
||||
},
|
||||
"name": "Target temperature settings"
|
||||
},
|
||||
"text_advanced_settings": {
|
||||
"data": {
|
||||
"max": "Maximum length",
|
||||
"min": "Mininum length",
|
||||
"mode": "Mode",
|
||||
"pattern": "Pattern"
|
||||
},
|
||||
"data_description": {
|
||||
"max": "Maximum length of the text input",
|
||||
"min": "Mininum length of the text input",
|
||||
"mode": "Mode of the text input",
|
||||
"pattern": "A valid regex pattern"
|
||||
},
|
||||
"name": "Advanced text settings"
|
||||
}
|
||||
},
|
||||
"title": "Configure MQTT device \"{mqtt_device}\""
|
||||
@@ -1387,7 +1402,8 @@
|
||||
"select": "[%key:component::select::title%]",
|
||||
"sensor": "[%key:component::sensor::title%]",
|
||||
"siren": "[%key:component::siren::title%]",
|
||||
"switch": "[%key:component::switch::title%]"
|
||||
"switch": "[%key:component::switch::title%]",
|
||||
"text": "[%key:component::text::title%]"
|
||||
}
|
||||
},
|
||||
"set_ca_cert": {
|
||||
@@ -1424,6 +1440,12 @@
|
||||
"none": "No target temperature",
|
||||
"single": "Single target temperature"
|
||||
}
|
||||
},
|
||||
"text_mode": {
|
||||
"options": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -27,7 +27,14 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType
|
||||
|
||||
from . import subscription
|
||||
from .config import MQTT_RW_SCHEMA
|
||||
from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC
|
||||
from .const import (
|
||||
CONF_COMMAND_TEMPLATE,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_MAX,
|
||||
CONF_MIN,
|
||||
CONF_PATTERN,
|
||||
CONF_STATE_TOPIC,
|
||||
)
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import (
|
||||
MqttCommandTemplate,
|
||||
@@ -42,12 +49,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CONF_MAX = "max"
|
||||
CONF_MIN = "min"
|
||||
CONF_PATTERN = "pattern"
|
||||
|
||||
DEFAULT_NAME = "MQTT Text"
|
||||
DEFAULT_PAYLOAD_RESET = "None"
|
||||
|
||||
MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset(
|
||||
{
|
||||
|
||||
@@ -7,7 +7,11 @@ from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
|
||||
from aioshelly.ble import get_name_from_model_id
|
||||
from aioshelly.ble.manufacturer_data import (
|
||||
has_rpc_over_ble,
|
||||
parse_shelly_manufacturer_data,
|
||||
)
|
||||
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
|
||||
from aioshelly.block_device import BlockDevice
|
||||
from aioshelly.common import ConnectionOptions, get_info
|
||||
@@ -358,8 +362,35 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle bluetooth discovery."""
|
||||
# Parse MAC address from the Bluetooth device name
|
||||
if not (mac := mac_address_from_name(discovery_info.name)):
|
||||
# Try to parse MAC address from the Bluetooth device name
|
||||
# If not found, try to get it from manufacturer data
|
||||
device_name = discovery_info.name
|
||||
if (
|
||||
not (mac := mac_address_from_name(device_name))
|
||||
and (
|
||||
parsed := parse_shelly_manufacturer_data(
|
||||
discovery_info.manufacturer_data
|
||||
)
|
||||
)
|
||||
and (mac_with_colons := parsed.get("mac"))
|
||||
and isinstance(mac_with_colons, str)
|
||||
):
|
||||
# parse_shelly_manufacturer_data returns MAC with colons (e.g., "CC:BA:97:C2:D6:72")
|
||||
# Convert to format without colons to match mac_address_from_name output
|
||||
mac = mac_with_colons.replace(":", "")
|
||||
# For devices without a Shelly name, use model name from model ID if available
|
||||
# Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR"
|
||||
if (
|
||||
(model_id := parsed.get("model_id"))
|
||||
and isinstance(model_id, int)
|
||||
and (model_name := get_name_from_model_id(model_id))
|
||||
):
|
||||
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
|
||||
device_name = f"{model_name.replace(' ', '')}-{mac}"
|
||||
else:
|
||||
device_name = f"Shelly-{mac}"
|
||||
|
||||
if not mac:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
|
||||
@@ -381,10 +412,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not self.ble_device:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.device_name = discovery_info.name
|
||||
self.device_name = device_name
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": discovery_info.name},
|
||||
"title_placeholders": {"name": device_name},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -32,11 +32,16 @@ from .utils import (
|
||||
async_remove_shelly_entity,
|
||||
get_block_channel,
|
||||
get_block_custom_name,
|
||||
get_block_number_of_channels,
|
||||
get_device_entry_gen,
|
||||
get_rpc_component_name,
|
||||
get_rpc_custom_name,
|
||||
get_rpc_entity_name,
|
||||
get_rpc_key,
|
||||
get_rpc_key_id,
|
||||
get_rpc_key_instances,
|
||||
get_rpc_number_of_channels,
|
||||
is_block_momentary_input,
|
||||
is_block_single_device,
|
||||
is_rpc_momentary_input,
|
||||
)
|
||||
|
||||
@@ -158,8 +163,7 @@ def _async_setup_rpc_entry(
|
||||
if script_name == BLE_SCRIPT_NAME:
|
||||
continue
|
||||
|
||||
script_id = int(script.split(":")[-1])
|
||||
if script_events and (event_types := script_events[script_id]):
|
||||
if script_events and (event_types := script_events[get_rpc_key_id(script)]):
|
||||
entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
|
||||
|
||||
# If a script is removed, from the device configuration, we need to remove orphaned entities
|
||||
@@ -197,13 +201,15 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
|
||||
self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES)
|
||||
self.entity_description = description
|
||||
|
||||
if (
|
||||
hasattr(self, "_attr_name")
|
||||
and self._attr_name
|
||||
and not get_block_custom_name(coordinator.device, block)
|
||||
if hasattr(self, "_attr_name") and not (
|
||||
(single := is_block_single_device(coordinator.device, block))
|
||||
and get_block_custom_name(coordinator.device, block)
|
||||
):
|
||||
self._attr_translation_placeholders = {
|
||||
"input_number": get_block_channel(block)
|
||||
if single
|
||||
and get_block_number_of_channels(coordinator.device, block) > 1
|
||||
else ""
|
||||
}
|
||||
|
||||
delattr(self, "_attr_name")
|
||||
@@ -237,22 +243,24 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
|
||||
) -> None:
|
||||
"""Initialize Shelly entity."""
|
||||
super().__init__(coordinator)
|
||||
self.event_id = int(key.split(":")[-1])
|
||||
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
|
||||
self._attr_unique_id = f"{coordinator.mac}-{key}"
|
||||
self.entity_description = description
|
||||
|
||||
if description.key == "input":
|
||||
component = key.split(":")[0]
|
||||
component_id = key.split(":")[-1]
|
||||
if not get_rpc_component_name(coordinator.device, key) and (
|
||||
component.lower() == "input" and component_id.isnumeric()
|
||||
):
|
||||
self._attr_translation_placeholders = {"input_number": component_id}
|
||||
_, component, component_id = get_rpc_key(key)
|
||||
if not get_rpc_custom_name(coordinator.device, key):
|
||||
self._attr_translation_placeholders = {
|
||||
"input_number": component_id
|
||||
if get_rpc_number_of_channels(coordinator.device, component) > 1
|
||||
else ""
|
||||
}
|
||||
else:
|
||||
self._attr_name = get_rpc_entity_name(coordinator.device, key)
|
||||
self.event_id = int(component_id)
|
||||
elif description.key == "script":
|
||||
self._attr_name = get_rpc_entity_name(coordinator.device, key)
|
||||
self.event_id = get_rpc_key_id(key)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "Shelly*"
|
||||
},
|
||||
{
|
||||
"manufacturer_id": 2985
|
||||
}
|
||||
],
|
||||
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
|
||||
@@ -13,8 +16,8 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioshelly==13.18.0"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioshelly==13.20.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "shelly*",
|
||||
|
||||
@@ -55,7 +55,7 @@ rules:
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
|
||||
@@ -93,8 +93,8 @@ def async_remove_shelly_entity(
|
||||
entity_reg.async_remove(entity_id)
|
||||
|
||||
|
||||
def get_number_of_channels(device: BlockDevice, block: Block) -> int:
|
||||
"""Get number of channels for block type."""
|
||||
def get_block_number_of_channels(device: BlockDevice, block: Block) -> int:
|
||||
"""Get number of channels."""
|
||||
channels = None
|
||||
|
||||
if block.type == "input":
|
||||
@@ -154,7 +154,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | No
|
||||
if (
|
||||
not block
|
||||
or block.type in ("device", "light", "relay", "emeter")
|
||||
or get_number_of_channels(device, block) == 1
|
||||
or get_block_number_of_channels(device, block) == 1
|
||||
):
|
||||
return None
|
||||
|
||||
@@ -253,7 +253,7 @@ def get_block_input_triggers(
|
||||
if not is_block_momentary_input(device.settings, block, True):
|
||||
return []
|
||||
|
||||
if block.type == "device" or get_number_of_channels(device, block) == 1:
|
||||
if block.type == "device" or get_block_number_of_channels(device, block) == 1:
|
||||
subtype = "button"
|
||||
else:
|
||||
assert block.channel
|
||||
@@ -397,8 +397,13 @@ def get_rpc_key(value: str) -> tuple[bool, str, str]:
|
||||
return len(parts) > 1, parts[0], parts[-1]
|
||||
|
||||
|
||||
def get_rpc_key_id(value: str) -> int:
|
||||
"""Get id from device key."""
|
||||
return int(get_rpc_key(value)[-1])
|
||||
|
||||
|
||||
def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
|
||||
"""Get component name from device config."""
|
||||
"""Get custom name from device config."""
|
||||
if (
|
||||
key in device.config
|
||||
and key != "em:0" # workaround for Pro 3EM, we don't want to get name for em:0
|
||||
@@ -409,9 +414,9 @@ def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
|
||||
"""Get component name from device config."""
|
||||
return get_rpc_custom_name(device, key)
|
||||
def get_rpc_number_of_channels(device: RpcDevice, component: str) -> int:
|
||||
"""Get number of channels."""
|
||||
return len(get_rpc_key_instances(device.status, component, all_lights=True))
|
||||
|
||||
|
||||
def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
|
||||
@@ -419,17 +424,15 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
|
||||
if BLU_TRV_IDENTIFIER in key:
|
||||
return None
|
||||
|
||||
instances = len(
|
||||
get_rpc_key_instances(device.status, key.split(":")[0], all_lights=True)
|
||||
)
|
||||
component = key.split(":")[0]
|
||||
component_id = key.split(":")[-1]
|
||||
_, component, component_id = get_rpc_key(key)
|
||||
|
||||
if custom_name := get_rpc_custom_name(device, key):
|
||||
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
|
||||
return custom_name
|
||||
|
||||
return custom_name if instances == 1 else None
|
||||
return (
|
||||
custom_name if get_rpc_number_of_channels(device, component) == 1 else None
|
||||
)
|
||||
|
||||
if component in (*VIRTUAL_COMPONENTS, "input"):
|
||||
return f"{component.title()} {component_id}"
|
||||
@@ -437,6 +440,14 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_rpc_key_normalized(key: str) -> str:
|
||||
"""Get normalized key. Workaround for Pro EM50 and Pro 3EM."""
|
||||
# workaround for Pro EM50
|
||||
key = key.replace("em1data", "em1")
|
||||
# workaround for Pro 3EM
|
||||
return key.replace("emdata", "em")
|
||||
|
||||
|
||||
def get_rpc_sub_device_name(
|
||||
device: RpcDevice, key: str, emeter_phase: str | None = None
|
||||
) -> str:
|
||||
@@ -451,11 +462,7 @@ def get_rpc_sub_device_name(
|
||||
if entity_name := device.config[key].get("name"):
|
||||
return cast(str, entity_name)
|
||||
|
||||
key = key.replace("emdata", "em")
|
||||
key = key.replace("em1data", "em1")
|
||||
|
||||
component = key.split(":")[0]
|
||||
component_id = key.split(":")[-1]
|
||||
_, component, component_id = get_rpc_key(get_rpc_key_normalized(key))
|
||||
|
||||
if component in ("cct", "rgb", "rgbw"):
|
||||
return f"{device.name} {component.upper()} light {component_id}"
|
||||
@@ -524,7 +531,7 @@ def get_rpc_key_instances(
|
||||
|
||||
def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]:
|
||||
"""Return list of key ids for RPC device from a dict."""
|
||||
return [int(k.split(":")[1]) for k in keys_dict if k.startswith(f"{key}:")]
|
||||
return [get_rpc_key_id(k) for k in keys_dict if k.startswith(f"{key}:")]
|
||||
|
||||
|
||||
def get_rpc_key_by_role(keys_dict: dict[str, Any], role: str) -> str | None:
|
||||
@@ -806,11 +813,10 @@ def is_rpc_exclude_from_relay(
|
||||
settings: dict[str, Any], status: dict[str, Any], channel: str
|
||||
) -> bool:
|
||||
"""Return true if rpc channel should be excludeed from switch platform."""
|
||||
ch = int(channel.split(":")[1])
|
||||
if is_rpc_thermostat_internal_actuator(status):
|
||||
return True
|
||||
|
||||
return is_rpc_channel_type_light(settings, ch)
|
||||
return is_rpc_channel_type_light(settings, get_rpc_key_id(channel))
|
||||
|
||||
|
||||
def get_shelly_air_lamp_life(lamp_seconds: int) -> float:
|
||||
@@ -832,7 +838,7 @@ async def get_rpc_scripts_event_types(
|
||||
if script_name in ignore_scripts:
|
||||
continue
|
||||
|
||||
script_id = int(script.split(":")[-1])
|
||||
script_id = get_rpc_key_id(script)
|
||||
script_events[script_id] = await get_rpc_script_event_types(device, script_id)
|
||||
|
||||
return script_events
|
||||
@@ -863,14 +869,8 @@ def get_rpc_device_info(
|
||||
if key is None:
|
||||
return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)})
|
||||
|
||||
# workaround for Pro EM50
|
||||
key = key.replace("em1data", "em1")
|
||||
# workaround for Pro 3EM
|
||||
key = key.replace("emdata", "em")
|
||||
|
||||
key_parts = key.split(":")
|
||||
component = key_parts[0]
|
||||
idx = key_parts[1] if len(key_parts) > 1 else None
|
||||
key = get_rpc_key_normalized(key)
|
||||
has_id, component, _ = get_rpc_key(key)
|
||||
|
||||
if emeter_phase is not None:
|
||||
return DeviceInfo(
|
||||
@@ -889,8 +889,8 @@ def get_rpc_device_info(
|
||||
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
|
||||
and get_irrigation_zone_id(device, key) is None
|
||||
)
|
||||
or idx is None
|
||||
or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2
|
||||
or not has_id
|
||||
or get_rpc_number_of_channels(device, component) < 2
|
||||
):
|
||||
return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)})
|
||||
|
||||
@@ -923,6 +923,15 @@ def get_blu_trv_device_info(
|
||||
)
|
||||
|
||||
|
||||
def is_block_single_device(device: BlockDevice, block: Block | None = None) -> bool:
|
||||
"""Return true if block is single device."""
|
||||
return (
|
||||
block is None
|
||||
or block.type not in ("light", "relay", "emeter")
|
||||
or device.settings.get("mode") == "roller"
|
||||
)
|
||||
|
||||
|
||||
def get_block_device_info(
|
||||
device: BlockDevice,
|
||||
mac: str,
|
||||
@@ -933,14 +942,14 @@ def get_block_device_info(
|
||||
suggested_area: str | None = None,
|
||||
) -> DeviceInfo:
|
||||
"""Return device info for Block device."""
|
||||
if (
|
||||
block is None
|
||||
or block.type not in ("light", "relay", "emeter")
|
||||
or device.settings.get("mode") == "roller"
|
||||
or get_number_of_channels(device, block) < 2
|
||||
if is_block_single_device(device, block) or (
|
||||
block is not None and get_block_number_of_channels(device, block) < 2
|
||||
):
|
||||
return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert block
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{mac}-{block.description}")},
|
||||
name=get_block_sub_device_name(device, block),
|
||||
|
||||
@@ -66,8 +66,6 @@ class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor):
|
||||
if webhook_id == self.webhook_id and data[ATTR_EVENT] in (
|
||||
"alarm_snooze_clicked",
|
||||
"alarm_snooze_canceled",
|
||||
"alarm_alert_start",
|
||||
"alarm_alert_dismiss",
|
||||
"alarm_skip_next",
|
||||
"show_skip_next_alarm",
|
||||
"alarm_rescheduled",
|
||||
|
||||
@@ -8,20 +8,17 @@ from dataclasses import dataclass
|
||||
from pysmartthings import Attribute, Capability, Category, SmartThings, Status
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import INVALID_SWITCH_CATEGORIES, MAIN
|
||||
from .entity import SmartThingsEntity
|
||||
from .util import deprecate_entity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -31,11 +28,14 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
is_on_key: str
|
||||
category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None
|
||||
category: set[Category] | None = None
|
||||
exists_fn: Callable[[str], bool] | None = None
|
||||
exists_fn: (
|
||||
Callable[
|
||||
[str, dict[str, dict[Capability | str, dict[Attribute | str, Status]]]],
|
||||
bool,
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
component_translation_key: dict[str, str] | None = None
|
||||
deprecated_fn: Callable[
|
||||
[dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None
|
||||
] = lambda _: None
|
||||
|
||||
|
||||
CAPABILITY_TO_SENSORS: dict[
|
||||
@@ -59,17 +59,16 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
Category.DOOR: BinarySensorDeviceClass.DOOR,
|
||||
Category.WINDOW: BinarySensorDeviceClass.WINDOW,
|
||||
},
|
||||
exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"},
|
||||
exists_fn=lambda component, status: (
|
||||
not ("freezer" in status and "cooler" in status)
|
||||
if component == MAIN
|
||||
else True
|
||||
),
|
||||
component_translation_key={
|
||||
"freezer": "freezer_door",
|
||||
"cooler": "cooler_door",
|
||||
"cvroom": "cool_select_plus_door",
|
||||
},
|
||||
deprecated_fn=(
|
||||
lambda status: "fridge_door"
|
||||
if "freezer" in status and "cooler" in status
|
||||
else None
|
||||
),
|
||||
)
|
||||
},
|
||||
Capability.CUSTOM_DRYER_WRINKLE_PREVENT: {
|
||||
@@ -155,15 +154,6 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
},
|
||||
Capability.VALVE: {
|
||||
Attribute.VALVE: SmartThingsBinarySensorEntityDescription(
|
||||
key=Attribute.VALVE,
|
||||
translation_key="valve",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
is_on_key="open",
|
||||
deprecated_fn=lambda _: "valve",
|
||||
)
|
||||
},
|
||||
Capability.WATER_SENSOR: {
|
||||
Attribute.WATER: SmartThingsBinarySensorEntityDescription(
|
||||
key=Attribute.WATER,
|
||||
@@ -204,64 +194,39 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add binary sensors for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
entities = []
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
|
||||
for capability, attribute_map in CAPABILITY_TO_SENSORS.items():
|
||||
for attribute, description in attribute_map.items():
|
||||
for component in device.status:
|
||||
if (
|
||||
capability in device.status[component]
|
||||
and (
|
||||
component == MAIN
|
||||
or (
|
||||
description.exists_fn is not None
|
||||
and description.exists_fn(component)
|
||||
)
|
||||
)
|
||||
and (
|
||||
not description.category
|
||||
or get_main_component_category(device)
|
||||
in description.category
|
||||
)
|
||||
):
|
||||
if (
|
||||
component == MAIN
|
||||
and (issue := description.deprecated_fn(device.status))
|
||||
is not None
|
||||
):
|
||||
if deprecate_entity(
|
||||
hass,
|
||||
entity_registry,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}",
|
||||
f"deprecated_binary_{issue}",
|
||||
):
|
||||
entities.append(
|
||||
SmartThingsBinarySensor(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
capability,
|
||||
attribute,
|
||||
component,
|
||||
)
|
||||
)
|
||||
continue
|
||||
entities.append(
|
||||
SmartThingsBinarySensor(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
capability,
|
||||
attribute,
|
||||
component,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
SmartThingsBinarySensor(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
capability,
|
||||
attribute,
|
||||
component,
|
||||
)
|
||||
for device in entry_data.devices.values()
|
||||
for capability, attribute_map in CAPABILITY_TO_SENSORS.items()
|
||||
for attribute, description in attribute_map.items()
|
||||
for component in device.status
|
||||
if (
|
||||
capability in device.status[component]
|
||||
and (
|
||||
component == MAIN
|
||||
or (
|
||||
description.component_translation_key is not None
|
||||
and component in description.component_translation_key
|
||||
)
|
||||
)
|
||||
and (
|
||||
description.exists_fn is None
|
||||
or description.exists_fn(component, device.status)
|
||||
)
|
||||
and (
|
||||
not description.category
|
||||
or get_main_component_category(device) in description.category
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
|
||||
|
||||
@@ -82,8 +82,14 @@
|
||||
"stop": "mdi:stop"
|
||||
}
|
||||
},
|
||||
"soil_level": {
|
||||
"default": "mdi:liquid-spot"
|
||||
},
|
||||
"spin_level": {
|
||||
"default": "mdi:rotate-right"
|
||||
},
|
||||
"water_temperature": {
|
||||
"default": "mdi:water-thermometer"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -56,6 +56,34 @@ WASHER_SPIN_LEVEL_TO_HA = {
|
||||
"1600": "1600",
|
||||
}
|
||||
|
||||
WASHER_WATER_TEMPERATURE_TO_HA = {
|
||||
"none": "none",
|
||||
"20": "20",
|
||||
"30": "30",
|
||||
"40": "40",
|
||||
"50": "50",
|
||||
"60": "60",
|
||||
"65": "65",
|
||||
"70": "70",
|
||||
"75": "75",
|
||||
"80": "80",
|
||||
"90": "90",
|
||||
"95": "95",
|
||||
"tapCold": "tap_cold",
|
||||
"cold": "cold",
|
||||
"cool": "cool",
|
||||
"ecoWarm": "eco_warm",
|
||||
"warm": "warm",
|
||||
"semiHot": "semi_hot",
|
||||
"hot": "hot",
|
||||
"extraHot": "extra_hot",
|
||||
"extraLow": "extra_low",
|
||||
"low": "low",
|
||||
"mediumLow": "medium_low",
|
||||
"medium": "medium",
|
||||
"high": "high",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SmartThingsSelectDescription(SelectEntityDescription):
|
||||
@@ -147,6 +175,16 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = {
|
||||
options_map=WASHER_SOIL_LEVEL_TO_HA,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
Capability.CUSTOM_WASHER_WATER_TEMPERATURE: SmartThingsSelectDescription(
|
||||
key=Capability.CUSTOM_WASHER_WATER_TEMPERATURE,
|
||||
translation_key="water_temperature",
|
||||
requires_remote_control_status=True,
|
||||
options_attribute=Attribute.SUPPORTED_WASHER_WATER_TEMPERATURE,
|
||||
status_attribute=Attribute.WASHER_WATER_TEMPERATURE,
|
||||
command=Command.SET_WASHER_WATER_TEMPERATURE,
|
||||
options_map=WASHER_WATER_TEMPERATURE_TO_HA,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -223,6 +223,36 @@
|
||||
"none": "None",
|
||||
"rinse_hold": "Rinse hold"
|
||||
}
|
||||
},
|
||||
"water_temperature": {
|
||||
"name": "Water temperature",
|
||||
"state": {
|
||||
"20": "20",
|
||||
"30": "30",
|
||||
"40": "40",
|
||||
"50": "50",
|
||||
"60": "60",
|
||||
"65": "65",
|
||||
"70": "70",
|
||||
"75": "75",
|
||||
"80": "80",
|
||||
"90": "90",
|
||||
"95": "95",
|
||||
"cold": "Cold",
|
||||
"cool": "Cool",
|
||||
"eco_warm": "Eco Warm",
|
||||
"extra_hot": "Extra Hot",
|
||||
"extra_low": "Extra Low",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"hot": "Hot",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"medium_low": "Medium Low",
|
||||
"none": "None",
|
||||
"semi_hot": "Semi Hot",
|
||||
"tap_cold": "Tap Cold",
|
||||
"warm": "Warm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -667,22 +697,6 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_binary_fridge_door": {
|
||||
"description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue.",
|
||||
"title": "Refrigerator door binary sensor deprecated"
|
||||
},
|
||||
"deprecated_binary_fridge_door_scripts": {
|
||||
"description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue.",
|
||||
"title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]"
|
||||
},
|
||||
"deprecated_binary_valve": {
|
||||
"description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue.",
|
||||
"title": "Valve binary sensor deprecated"
|
||||
},
|
||||
"deprecated_binary_valve_scripts": {
|
||||
"description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue.",
|
||||
"title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]"
|
||||
},
|
||||
"deprecated_dhw": {
|
||||
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nPlease update your dashboards and templates to use the new water heater entity and disable the sensor to fix this issue.",
|
||||
"title": "Water heater sensors deprecated"
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["switchbot"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["PySwitchbot==0.73.0"]
|
||||
"requirements": ["PySwitchbot==0.74.0"]
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ PLATFORMS: Final = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData]
|
||||
|
||||
@@ -576,6 +576,11 @@
|
||||
"vehicle_state_valet_mode": {
|
||||
"name": "Valet mode"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"vehicle_state_software_update_status": {
|
||||
"name": "[%key:component::update::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
114
homeassistant/components/tesla_fleet/update.py
Normal file
114
homeassistant/components/tesla_fleet/update.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Update platform for Tesla Fleet integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet
|
||||
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TeslaFleetConfigEntry
|
||||
from .entity import TeslaFleetVehicleEntity
|
||||
from .helpers import handle_vehicle_command
|
||||
from .models import TeslaFleetVehicleData
|
||||
|
||||
AVAILABLE = "available"
|
||||
DOWNLOADING = "downloading"
|
||||
INSTALLING = "installing"
|
||||
WIFI_WAIT = "downloading_wifi_wait"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TeslaFleetConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Tesla Fleet update platform from a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
TeslaFleetUpdateEntity(vehicle, entry.runtime_data.scopes)
|
||||
for vehicle in entry.runtime_data.vehicles
|
||||
)
|
||||
|
||||
|
||||
class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
|
||||
"""Tesla Fleet Update entity."""
|
||||
|
||||
_attr_supported_features = UpdateEntityFeature.PROGRESS
|
||||
api: VehicleFleet
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: TeslaFleetVehicleData,
|
||||
scopes: list[Scope],
|
||||
) -> None:
|
||||
"""Initialize the Update."""
|
||||
self.scoped = Scope.VEHICLE_CMDS in scopes
|
||||
super().__init__(
|
||||
data,
|
||||
"vehicle_state_software_update_status",
|
||||
)
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self.raise_for_read_only(Scope.VEHICLE_CMDS)
|
||||
|
||||
await handle_vehicle_command(self.api.schedule_software_update(offset_sec=0))
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update the attributes of the entity."""
|
||||
|
||||
# Supported Features
|
||||
if self.scoped and self._value in (
|
||||
AVAILABLE,
|
||||
SCHEDULED,
|
||||
):
|
||||
# Only allow install when an update has been fully downloaded
|
||||
self._attr_supported_features = (
|
||||
UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL
|
||||
)
|
||||
else:
|
||||
self._attr_supported_features = UpdateEntityFeature.PROGRESS
|
||||
|
||||
# Installed Version
|
||||
self._attr_installed_version = self.get("vehicle_state_car_version")
|
||||
if self._attr_installed_version is not None:
|
||||
# Remove build from version
|
||||
self._attr_installed_version = self._attr_installed_version.split(" ")[0]
|
||||
|
||||
# Latest Version
|
||||
if self._value in (
|
||||
AVAILABLE,
|
||||
SCHEDULED,
|
||||
INSTALLING,
|
||||
DOWNLOADING,
|
||||
WIFI_WAIT,
|
||||
):
|
||||
self._attr_latest_version = self.coordinator.data[
|
||||
"vehicle_state_software_update_version"
|
||||
]
|
||||
else:
|
||||
self._attr_latest_version = self._attr_installed_version
|
||||
|
||||
# In Progress
|
||||
if self._value in (
|
||||
SCHEDULED,
|
||||
INSTALLING,
|
||||
):
|
||||
self._attr_in_progress = True
|
||||
if install_perc := self.get("vehicle_state_software_update_install_perc"):
|
||||
self._attr_update_percentage = install_perc
|
||||
else:
|
||||
self._attr_in_progress = False
|
||||
self._attr_update_percentage = None
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -26,8 +26,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeIntegerWrapper, IntegerTypeData, find_dpcode
|
||||
from .util import get_dpcode
|
||||
from .models import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
find_dpcode,
|
||||
)
|
||||
|
||||
TUYA_HVAC_TO_HA = {
|
||||
"auto": HVACMode.HEAT_COOL,
|
||||
@@ -86,6 +90,82 @@ CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = {
|
||||
}
|
||||
|
||||
|
||||
def _get_temperature_wrappers(
|
||||
device: CustomerDevice, system_temperature_unit: UnitOfTemperature
|
||||
) -> tuple[DPCodeIntegerWrapper | None, DPCodeIntegerWrapper | None, UnitOfTemperature]:
|
||||
"""Get temperature wrappers for current and set temperatures."""
|
||||
current_temperature_wrapper: DPCodeIntegerWrapper | None = None
|
||||
set_temperature_wrapper: DPCodeIntegerWrapper | None = None
|
||||
|
||||
# Default to System Temperature Unit
|
||||
temperature_unit = system_temperature_unit
|
||||
|
||||
# If both temperature values for celsius and fahrenheit are present,
|
||||
# use whatever the device is set to, with a fallback to celsius.
|
||||
preferred_temperature_unit = None
|
||||
if all(
|
||||
dpcode in device.status
|
||||
for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F)
|
||||
) or all(
|
||||
dpcode in device.status for dpcode in (DPCode.TEMP_SET, DPCode.TEMP_SET_F)
|
||||
):
|
||||
preferred_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
if any(
|
||||
"f" in device.status[dpcode].lower()
|
||||
for dpcode in (DPCode.C_F, DPCode.TEMP_UNIT_CONVERT)
|
||||
if isinstance(device.status.get(dpcode), str)
|
||||
):
|
||||
preferred_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
|
||||
# Figure out current temperature, use preferred unit or what is available
|
||||
celsius_type = find_dpcode(
|
||||
device, (DPCode.TEMP_CURRENT, DPCode.UPPER_TEMP), dptype=DPType.INTEGER
|
||||
)
|
||||
fahrenheit_type = find_dpcode(
|
||||
device,
|
||||
(DPCode.TEMP_CURRENT_F, DPCode.UPPER_TEMP_F),
|
||||
dptype=DPType.INTEGER,
|
||||
)
|
||||
if fahrenheit_type and (
|
||||
preferred_temperature_unit == UnitOfTemperature.FAHRENHEIT
|
||||
or (
|
||||
preferred_temperature_unit == UnitOfTemperature.CELSIUS and not celsius_type
|
||||
)
|
||||
):
|
||||
temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
current_temperature_wrapper = DPCodeIntegerWrapper(
|
||||
fahrenheit_type.dpcode, fahrenheit_type
|
||||
)
|
||||
elif celsius_type:
|
||||
temperature_unit = UnitOfTemperature.CELSIUS
|
||||
current_temperature_wrapper = DPCodeIntegerWrapper(
|
||||
celsius_type.dpcode, celsius_type
|
||||
)
|
||||
|
||||
# Figure out setting temperature, use preferred unit or what is available
|
||||
celsius_type = find_dpcode(
|
||||
device, DPCode.TEMP_SET, dptype=DPType.INTEGER, prefer_function=True
|
||||
)
|
||||
fahrenheit_type = find_dpcode(
|
||||
device, DPCode.TEMP_SET_F, dptype=DPType.INTEGER, prefer_function=True
|
||||
)
|
||||
if fahrenheit_type and (
|
||||
preferred_temperature_unit == UnitOfTemperature.FAHRENHEIT
|
||||
or (
|
||||
preferred_temperature_unit == UnitOfTemperature.CELSIUS and not celsius_type
|
||||
)
|
||||
):
|
||||
set_temperature_wrapper = DPCodeIntegerWrapper(
|
||||
fahrenheit_type.dpcode, fahrenheit_type
|
||||
)
|
||||
elif celsius_type:
|
||||
set_temperature_wrapper = DPCodeIntegerWrapper(
|
||||
celsius_type.dpcode, celsius_type
|
||||
)
|
||||
|
||||
return current_temperature_wrapper, set_temperature_wrapper, temperature_unit
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -101,18 +181,43 @@ async def async_setup_entry(
|
||||
for device_id in device_ids:
|
||||
device = manager.device_map[device_id]
|
||||
if device and device.category in CLIMATE_DESCRIPTIONS:
|
||||
temperature_wrappers = _get_temperature_wrappers(
|
||||
device, hass.config.units.temperature_unit
|
||||
)
|
||||
entities.append(
|
||||
TuyaClimateEntity(
|
||||
device,
|
||||
manager,
|
||||
CLIMATE_DESCRIPTIONS[device.category],
|
||||
hass.config.units.temperature_unit,
|
||||
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_CURRENT
|
||||
),
|
||||
current_temperature_wrapper=temperature_wrappers[0],
|
||||
fan_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device,
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
prefer_function=True,
|
||||
),
|
||||
hvac_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
set_temperature_wrapper=temperature_wrappers[1],
|
||||
swing_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, (DPCode.SWING, DPCode.SHAKE), prefer_function=True
|
||||
),
|
||||
swing_h_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH_HORIZONTAL, prefer_function=True
|
||||
),
|
||||
swing_v_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH_VERTICAL, prefer_function=True
|
||||
),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH, prefer_function=True
|
||||
),
|
||||
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_SET, prefer_function=True
|
||||
),
|
||||
temperature_unit=temperature_wrappers[2],
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
@@ -127,9 +232,7 @@ async def async_setup_entry(
|
||||
class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
"""Tuya Climate Device."""
|
||||
|
||||
_current_temperature: IntegerTypeData | None = None
|
||||
_hvac_to_tuya: dict[str, str]
|
||||
_set_temperature: IntegerTypeData | None = None
|
||||
entity_description: TuyaClimateEntityDescription
|
||||
_attr_name = None
|
||||
|
||||
@@ -138,10 +241,18 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaClimateEntityDescription,
|
||||
system_temperature_unit: UnitOfTemperature,
|
||||
*,
|
||||
current_humidity_wrapper: _RoundedIntegerWrapper | None = None,
|
||||
target_humidity_wrapper: _RoundedIntegerWrapper | None = None,
|
||||
current_humidity_wrapper: _RoundedIntegerWrapper | None,
|
||||
current_temperature_wrapper: DPCodeIntegerWrapper | None,
|
||||
fan_mode_wrapper: DPCodeEnumWrapper | None,
|
||||
hvac_mode_wrapper: DPCodeEnumWrapper | None,
|
||||
set_temperature_wrapper: DPCodeIntegerWrapper | None,
|
||||
swing_wrapper: DPCodeBooleanWrapper | None,
|
||||
swing_h_wrapper: DPCodeBooleanWrapper | None,
|
||||
swing_v_wrapper: DPCodeBooleanWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
target_humidity_wrapper: _RoundedIntegerWrapper | None,
|
||||
temperature_unit: UnitOfTemperature,
|
||||
) -> None:
|
||||
"""Determine which values to use."""
|
||||
self._attr_target_temperature_step = 1.0
|
||||
@@ -149,85 +260,34 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
super().__init__(device, device_manager)
|
||||
self._current_humidity_wrapper = current_humidity_wrapper
|
||||
self._current_temperature = current_temperature_wrapper
|
||||
self._fan_mode_wrapper = fan_mode_wrapper
|
||||
self._hvac_mode_wrapper = hvac_mode_wrapper
|
||||
self._set_temperature = set_temperature_wrapper
|
||||
self._swing_wrapper = swing_wrapper
|
||||
self._swing_h_wrapper = swing_h_wrapper
|
||||
self._swing_v_wrapper = swing_v_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
self._target_humidity_wrapper = target_humidity_wrapper
|
||||
|
||||
# If both temperature values for celsius and fahrenheit are present,
|
||||
# use whatever the device is set to, with a fallback to celsius.
|
||||
prefered_temperature_unit = None
|
||||
if all(
|
||||
dpcode in device.status
|
||||
for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F)
|
||||
) or all(
|
||||
dpcode in device.status for dpcode in (DPCode.TEMP_SET, DPCode.TEMP_SET_F)
|
||||
):
|
||||
prefered_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
if any(
|
||||
"f" in device.status[dpcode].lower()
|
||||
for dpcode in (DPCode.C_F, DPCode.TEMP_UNIT_CONVERT)
|
||||
if isinstance(device.status.get(dpcode), str)
|
||||
):
|
||||
prefered_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
|
||||
# Default to System Temperature Unit
|
||||
self._attr_temperature_unit = system_temperature_unit
|
||||
|
||||
# Figure out current temperature, use preferred unit or what is available
|
||||
celsius_type = find_dpcode(
|
||||
self.device, (DPCode.TEMP_CURRENT, DPCode.UPPER_TEMP), dptype=DPType.INTEGER
|
||||
)
|
||||
fahrenheit_type = find_dpcode(
|
||||
self.device,
|
||||
(DPCode.TEMP_CURRENT_F, DPCode.UPPER_TEMP_F),
|
||||
dptype=DPType.INTEGER,
|
||||
)
|
||||
if fahrenheit_type and (
|
||||
prefered_temperature_unit == UnitOfTemperature.FAHRENHEIT
|
||||
or (
|
||||
prefered_temperature_unit == UnitOfTemperature.CELSIUS
|
||||
and not celsius_type
|
||||
)
|
||||
):
|
||||
self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
self._current_temperature = fahrenheit_type
|
||||
elif celsius_type:
|
||||
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._current_temperature = celsius_type
|
||||
|
||||
# Figure out setting temperature, use preferred unit or what is available
|
||||
celsius_type = find_dpcode(
|
||||
self.device, DPCode.TEMP_SET, dptype=DPType.INTEGER, prefer_function=True
|
||||
)
|
||||
fahrenheit_type = find_dpcode(
|
||||
self.device, DPCode.TEMP_SET_F, dptype=DPType.INTEGER, prefer_function=True
|
||||
)
|
||||
if fahrenheit_type and (
|
||||
prefered_temperature_unit == UnitOfTemperature.FAHRENHEIT
|
||||
or (
|
||||
prefered_temperature_unit == UnitOfTemperature.CELSIUS
|
||||
and not celsius_type
|
||||
)
|
||||
):
|
||||
self._set_temperature = fahrenheit_type
|
||||
elif celsius_type:
|
||||
self._set_temperature = celsius_type
|
||||
self._attr_temperature_unit = temperature_unit
|
||||
|
||||
# Get integer type data for the dpcode to set temperature, use
|
||||
# it to define min, max & step temperatures
|
||||
if self._set_temperature:
|
||||
self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
self._attr_max_temp = self._set_temperature.max_scaled
|
||||
self._attr_min_temp = self._set_temperature.min_scaled
|
||||
self._attr_target_temperature_step = self._set_temperature.step_scaled
|
||||
self._attr_max_temp = self._set_temperature.type_information.max_scaled
|
||||
self._attr_min_temp = self._set_temperature.type_information.min_scaled
|
||||
self._attr_target_temperature_step = (
|
||||
self._set_temperature.type_information.step_scaled
|
||||
)
|
||||
|
||||
# Determine HVAC modes
|
||||
self._attr_hvac_modes: list[HVACMode] = []
|
||||
self._hvac_to_tuya = {}
|
||||
if enum_type := find_dpcode(
|
||||
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if hvac_mode_wrapper:
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
unknown_hvac_modes: list[str] = []
|
||||
for tuya_mode in enum_type.range:
|
||||
for tuya_mode in hvac_mode_wrapper.type_information.range:
|
||||
if tuya_mode in TUYA_HVAC_TO_HA:
|
||||
ha_mode = TUYA_HVAC_TO_HA[tuya_mode]
|
||||
self._hvac_to_tuya[ha_mode] = tuya_mode
|
||||
@@ -239,7 +299,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
|
||||
self._attr_preset_modes = unknown_hvac_modes
|
||||
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
|
||||
elif get_dpcode(self.device, DPCode.SWITCH):
|
||||
elif switch_wrapper:
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
description.switch_only_hvac_mode,
|
||||
@@ -256,129 +316,91 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
# Determine fan modes
|
||||
self._fan_mode_dp_code: str | None = None
|
||||
if enum_type := find_dpcode(
|
||||
self.device,
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
):
|
||||
if fan_mode_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
|
||||
self._attr_fan_modes = enum_type.range
|
||||
self._fan_mode_dp_code = enum_type.dpcode
|
||||
self._attr_fan_modes = fan_mode_wrapper.type_information.range
|
||||
|
||||
# Determine swing modes
|
||||
if get_dpcode(
|
||||
self.device,
|
||||
(
|
||||
DPCode.SHAKE,
|
||||
DPCode.SWING,
|
||||
DPCode.SWITCH_HORIZONTAL,
|
||||
DPCode.SWITCH_VERTICAL,
|
||||
),
|
||||
):
|
||||
if swing_wrapper or swing_h_wrapper or swing_v_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
self._attr_swing_modes = [SWING_OFF]
|
||||
if get_dpcode(self.device, (DPCode.SHAKE, DPCode.SWING)):
|
||||
if swing_wrapper:
|
||||
self._attr_swing_modes.append(SWING_ON)
|
||||
|
||||
if get_dpcode(self.device, DPCode.SWITCH_HORIZONTAL):
|
||||
if swing_h_wrapper:
|
||||
self._attr_swing_modes.append(SWING_HORIZONTAL)
|
||||
|
||||
if get_dpcode(self.device, DPCode.SWITCH_VERTICAL):
|
||||
if swing_v_wrapper:
|
||||
self._attr_swing_modes.append(SWING_VERTICAL)
|
||||
|
||||
if DPCode.SWITCH in self.device.function:
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
commands = [{"code": DPCode.SWITCH, "value": hvac_mode != HVACMode.OFF}]
|
||||
if hvac_mode in self._hvac_to_tuya:
|
||||
commands = []
|
||||
if self._switch_wrapper:
|
||||
commands.append(
|
||||
{"code": DPCode.MODE, "value": self._hvac_to_tuya[hvac_mode]}
|
||||
self._switch_wrapper.get_update_command(
|
||||
self.device, hvac_mode != HVACMode.OFF
|
||||
)
|
||||
)
|
||||
self._send_command(commands)
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
|
||||
commands.append(
|
||||
self._hvac_mode_wrapper.get_update_command(
|
||||
self.device, self._hvac_to_tuya[hvac_mode]
|
||||
)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
commands = [{"code": DPCode.MODE, "value": preset_mode}]
|
||||
self._send_command(commands)
|
||||
await self._async_send_dpcode_update(self._hvac_mode_wrapper, preset_mode)
|
||||
|
||||
def set_fan_mode(self, fan_mode: str) -> None:
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
if TYPE_CHECKING:
|
||||
# guarded by ClimateEntityFeature.FAN_MODE
|
||||
assert self._fan_mode_dp_code is not None
|
||||
|
||||
self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}])
|
||||
await self._async_send_dpcode_update(self._fan_mode_wrapper, fan_mode)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
|
||||
|
||||
def set_swing_mode(self, swing_mode: str) -> None:
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new target swing operation."""
|
||||
# The API accepts these all at once and will ignore the codes
|
||||
# that don't apply to the device being controlled.
|
||||
self._send_command(
|
||||
[
|
||||
{
|
||||
"code": DPCode.SHAKE,
|
||||
"value": swing_mode == SWING_ON,
|
||||
},
|
||||
{
|
||||
"code": DPCode.SWING,
|
||||
"value": swing_mode == SWING_ON,
|
||||
},
|
||||
{
|
||||
"code": DPCode.SWITCH_VERTICAL,
|
||||
"value": swing_mode in (SWING_BOTH, SWING_VERTICAL),
|
||||
},
|
||||
{
|
||||
"code": DPCode.SWITCH_HORIZONTAL,
|
||||
"value": swing_mode in (SWING_BOTH, SWING_HORIZONTAL),
|
||||
},
|
||||
]
|
||||
)
|
||||
commands = []
|
||||
if self._swing_wrapper:
|
||||
commands.append(
|
||||
self._swing_wrapper.get_update_command(
|
||||
self.device, swing_mode == SWING_ON
|
||||
)
|
||||
)
|
||||
if self._swing_v_wrapper:
|
||||
commands.append(
|
||||
self._swing_v_wrapper.get_update_command(
|
||||
self.device, swing_mode in (SWING_BOTH, SWING_VERTICAL)
|
||||
)
|
||||
)
|
||||
if self._swing_h_wrapper:
|
||||
commands.append(
|
||||
self._swing_h_wrapper.get_update_command(
|
||||
self.device, swing_mode in (SWING_BOTH, SWING_HORIZONTAL)
|
||||
)
|
||||
)
|
||||
if commands:
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
def set_temperature(self, **kwargs: Any) -> None:
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if TYPE_CHECKING:
|
||||
# guarded by ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
assert self._set_temperature is not None
|
||||
|
||||
self._send_command(
|
||||
[
|
||||
{
|
||||
"code": self._set_temperature.dpcode,
|
||||
"value": round(
|
||||
self._set_temperature.scale_value_back(kwargs[ATTR_TEMPERATURE])
|
||||
),
|
||||
}
|
||||
]
|
||||
await self._async_send_dpcode_update(
|
||||
self._set_temperature, kwargs[ATTR_TEMPERATURE]
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self._current_temperature is None:
|
||||
return None
|
||||
|
||||
temperature = self.device.status.get(self._current_temperature.dpcode)
|
||||
if temperature is None:
|
||||
return None
|
||||
|
||||
if self._current_temperature.scale == 0 and self._current_temperature.step != 1:
|
||||
# The current temperature can have a scale of 0 or 1 and is used for
|
||||
# rounding, Home Assistant doesn't need to round but we will always
|
||||
# need to divide the value by 10^1 in case of 0 as scale.
|
||||
# https://developer.tuya.com/en/docs/iot/shift-temperature-scale-follow-the-setting-of-app-account-center?id=Ka9qo7so58efq#title-7-Round%20values
|
||||
temperature = temperature / 10
|
||||
|
||||
return self._current_temperature.scale_value(temperature)
|
||||
return self._read_wrapper(self._current_temperature)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> int | None:
|
||||
@@ -388,14 +410,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature currently set to be reached."""
|
||||
if self._set_temperature is None:
|
||||
return None
|
||||
|
||||
temperature = self.device.status.get(self._set_temperature.dpcode)
|
||||
if temperature is None:
|
||||
return None
|
||||
|
||||
return self._set_temperature.scale_value(temperature)
|
||||
return self._read_wrapper(self._set_temperature)
|
||||
|
||||
@property
|
||||
def target_humidity(self) -> int | None:
|
||||
@@ -405,34 +420,29 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac mode."""
|
||||
# If the switch off, hvac mode is off as well. Unless the switch
|
||||
# the switch is on or doesn't exists of course...
|
||||
if not self.device.status.get(DPCode.SWITCH, True):
|
||||
# If the switch is off, hvac mode is off as well.
|
||||
# Unless the switch doesn't exists of course...
|
||||
if (switch_status := self._read_wrapper(self._switch_wrapper)) is False:
|
||||
return HVACMode.OFF
|
||||
|
||||
if DPCode.MODE not in self.device.function:
|
||||
if self.device.status.get(DPCode.SWITCH, False):
|
||||
return self.entity_description.switch_only_hvac_mode
|
||||
return HVACMode.OFF
|
||||
# If the mode is known and maps to an HVAC mode, return it.
|
||||
if (mode := self._read_wrapper(self._hvac_mode_wrapper)) and (
|
||||
hvac_mode := TUYA_HVAC_TO_HA.get(mode)
|
||||
):
|
||||
return hvac_mode
|
||||
|
||||
if (
|
||||
mode := self.device.status.get(DPCode.MODE)
|
||||
) is not None and mode in TUYA_HVAC_TO_HA:
|
||||
return TUYA_HVAC_TO_HA[mode]
|
||||
|
||||
# If the switch is on, and the mode does not match any hvac mode.
|
||||
if self.device.status.get(DPCode.SWITCH, False):
|
||||
# If hvac_mode is unknown, return the switch only mode.
|
||||
if switch_status:
|
||||
return self.entity_description.switch_only_hvac_mode
|
||||
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return preset mode."""
|
||||
if DPCode.MODE not in self.device.function:
|
||||
if self._hvac_mode_wrapper is None:
|
||||
return None
|
||||
|
||||
mode = self.device.status.get(DPCode.MODE)
|
||||
mode = self._read_wrapper(self._hvac_mode_wrapper)
|
||||
if mode in TUYA_HVAC_TO_HA:
|
||||
return None
|
||||
|
||||
@@ -441,22 +451,16 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return fan mode."""
|
||||
return (
|
||||
self.device.status.get(self._fan_mode_dp_code)
|
||||
if self._fan_mode_dp_code
|
||||
else None
|
||||
)
|
||||
return self._read_wrapper(self._fan_mode_wrapper)
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
"""Return swing mode."""
|
||||
if any(
|
||||
self.device.status.get(dpcode) for dpcode in (DPCode.SHAKE, DPCode.SWING)
|
||||
):
|
||||
if self._read_wrapper(self._swing_wrapper):
|
||||
return SWING_ON
|
||||
|
||||
horizontal = self.device.status.get(DPCode.SWITCH_HORIZONTAL)
|
||||
vertical = self.device.status.get(DPCode.SWITCH_VERTICAL)
|
||||
horizontal = self._read_wrapper(self._swing_h_wrapper)
|
||||
vertical = self._read_wrapper(self._swing_v_wrapper)
|
||||
if horizontal and vertical:
|
||||
return SWING_BOTH
|
||||
if horizontal:
|
||||
@@ -466,10 +470,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
return SWING_OFF
|
||||
|
||||
def turn_on(self) -> None:
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
self._send_command([{"code": DPCode.SWITCH, "value": True}])
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, True)
|
||||
|
||||
def turn_off(self) -> None:
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
self._send_command([{"code": DPCode.SWITCH, "value": False}])
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
|
||||
@@ -20,10 +20,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeIntegerWrapper, find_dpcode
|
||||
from .util import get_dpcode
|
||||
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper
|
||||
|
||||
|
||||
class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper):
|
||||
@@ -73,20 +72,106 @@ class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper):
|
||||
return device.status.get(DPCode.CONTROL_BACK_MODE) != "back"
|
||||
|
||||
|
||||
class _InstructionWrapper:
|
||||
"""Default wrapper for sending open/close/stop instructions."""
|
||||
|
||||
def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
class _InstructionBooleanWrapper(DPCodeBooleanWrapper, _InstructionWrapper):
|
||||
"""Wrapper for boolean-based open/close instructions."""
|
||||
|
||||
def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return {"code": self.dpcode, "value": True}
|
||||
|
||||
def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return {"code": self.dpcode, "value": False}
|
||||
|
||||
|
||||
class _InstructionEnumWrapper(DPCodeEnumWrapper, _InstructionWrapper):
|
||||
"""Wrapper for enum-based open/close/stop instructions."""
|
||||
|
||||
open_instruction = "open"
|
||||
close_instruction = "close"
|
||||
stop_instruction = "stop"
|
||||
|
||||
def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
if self.open_instruction in self.type_information.range:
|
||||
return {"code": self.dpcode, "value": self.open_instruction}
|
||||
return None
|
||||
|
||||
def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
if self.close_instruction in self.type_information.range:
|
||||
return {"code": self.dpcode, "value": self.close_instruction}
|
||||
return None
|
||||
|
||||
def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
if self.stop_instruction in self.type_information.range:
|
||||
return {"code": self.dpcode, "value": self.stop_instruction}
|
||||
return None
|
||||
|
||||
|
||||
class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper):
|
||||
"""Wrapper for enum-based instructions with special values (FZ/ZZ/STOP)."""
|
||||
|
||||
open_instruction = "FZ"
|
||||
close_instruction = "ZZ"
|
||||
stop_instruction = "STOP"
|
||||
|
||||
|
||||
class _IsClosedWrapper:
|
||||
"""Wrapper for checking if cover is closed."""
|
||||
|
||||
def is_closed(self, device: CustomerDevice) -> bool | None:
|
||||
return None
|
||||
|
||||
|
||||
class _IsClosedInvertedWrapper(DPCodeBooleanWrapper, _IsClosedWrapper):
|
||||
"""Boolean wrapper for checking if cover is closed (inverted)."""
|
||||
|
||||
def is_closed(self, device: CustomerDevice) -> bool | None:
|
||||
if (value := self.read_device_status(device)) is None:
|
||||
return None
|
||||
return not value
|
||||
|
||||
|
||||
class _IsClosedEnumWrapper(DPCodeEnumWrapper, _IsClosedWrapper):
|
||||
"""Enum wrapper for checking if state is closed."""
|
||||
|
||||
_MAPPINGS = {
|
||||
"close": True,
|
||||
"fully_close": True,
|
||||
"open": False,
|
||||
"fully_open": False,
|
||||
}
|
||||
|
||||
def is_closed(self, device: CustomerDevice) -> bool | None:
|
||||
if (value := self.read_device_status(device)) is None:
|
||||
return None
|
||||
return self._MAPPINGS.get(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaCoverEntityDescription(CoverEntityDescription):
|
||||
"""Describe an Tuya cover entity."""
|
||||
|
||||
current_state: DPCode | tuple[DPCode, ...] | None = None
|
||||
current_state_inverse: bool = False
|
||||
current_state_wrapper: type[_IsClosedInvertedWrapper | _IsClosedEnumWrapper] = (
|
||||
_IsClosedEnumWrapper
|
||||
)
|
||||
current_position: DPCode | tuple[DPCode, ...] | None = None
|
||||
instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper
|
||||
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
|
||||
_InvertedPercentageMappingWrapper
|
||||
)
|
||||
set_position: DPCode | None = None
|
||||
open_instruction_value: str = "open"
|
||||
close_instruction_value: str = "close"
|
||||
stop_instruction_value: str = "stop"
|
||||
|
||||
|
||||
COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
@@ -96,7 +181,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "1"},
|
||||
current_state=DPCode.DOORCONTACT_STATE,
|
||||
current_state_inverse=True,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
TuyaCoverEntityDescription(
|
||||
@@ -104,7 +189,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "2"},
|
||||
current_state=DPCode.DOORCONTACT_STATE_2,
|
||||
current_state_inverse=True,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
TuyaCoverEntityDescription(
|
||||
@@ -112,7 +197,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "3"},
|
||||
current_state=DPCode.DOORCONTACT_STATE_3,
|
||||
current_state_inverse=True,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
),
|
||||
@@ -147,9 +232,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
current_position=DPCode.POSITION,
|
||||
set_position=DPCode.POSITION,
|
||||
device_class=CoverDeviceClass.CURTAIN,
|
||||
open_instruction_value="FZ",
|
||||
close_instruction_value="ZZ",
|
||||
stop_instruction_value="STOP",
|
||||
instruction_wrapper=_SpecialInstructionEnumWrapper,
|
||||
),
|
||||
# switch_1 is an undocumented code that behaves identically to control
|
||||
# It is used by the Kogan Smart Blinds Driver
|
||||
@@ -192,6 +275,21 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
}
|
||||
|
||||
|
||||
def _get_instruction_wrapper(
|
||||
device: CustomerDevice, description: TuyaCoverEntityDescription
|
||||
) -> _InstructionWrapper | None:
|
||||
"""Get the instruction wrapper for the cover entity."""
|
||||
if enum_wrapper := description.instruction_wrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
):
|
||||
return enum_wrapper
|
||||
|
||||
# Fallback to a boolean wrapper if available
|
||||
return _InstructionBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -215,6 +313,12 @@ async def async_setup_entry(
|
||||
current_position=description.position_wrapper.find_dpcode(
|
||||
device, description.current_position
|
||||
),
|
||||
instruction_wrapper=_get_instruction_wrapper(
|
||||
device, description
|
||||
),
|
||||
current_state_wrapper=description.current_state_wrapper.find_dpcode(
|
||||
device, description.current_state
|
||||
),
|
||||
set_position=description.position_wrapper.find_dpcode(
|
||||
device, description.set_position, prefer_function=True
|
||||
),
|
||||
@@ -243,7 +347,6 @@ async def async_setup_entry(
|
||||
class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
"""Tuya Cover Device."""
|
||||
|
||||
_current_state: DPCode | None = None
|
||||
entity_description: TuyaCoverEntityDescription
|
||||
|
||||
def __init__(
|
||||
@@ -252,9 +355,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
device_manager: Manager,
|
||||
description: TuyaCoverEntityDescription,
|
||||
*,
|
||||
current_position: _DPCodePercentageMappingWrapper | None = None,
|
||||
set_position: _DPCodePercentageMappingWrapper | None = None,
|
||||
tilt_position: _DPCodePercentageMappingWrapper | None = None,
|
||||
current_position: _DPCodePercentageMappingWrapper | None,
|
||||
current_state_wrapper: _IsClosedWrapper | None,
|
||||
instruction_wrapper: _InstructionWrapper | None,
|
||||
set_position: _DPCodePercentageMappingWrapper | None,
|
||||
tilt_position: _DPCodePercentageMappingWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya Cover."""
|
||||
super().__init__(device, device_manager)
|
||||
@@ -263,26 +368,18 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
self._attr_supported_features = CoverEntityFeature(0)
|
||||
|
||||
self._current_position = current_position or set_position
|
||||
self._current_state_wrapper = current_state_wrapper
|
||||
self._instruction_wrapper = instruction_wrapper
|
||||
self._set_position = set_position
|
||||
self._tilt_position = tilt_position
|
||||
|
||||
# Check if this cover is based on a switch or has controls
|
||||
if get_dpcode(self.device, description.key):
|
||||
if device.function[description.key].type == "Boolean":
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
elif enum_type := find_dpcode(
|
||||
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if description.open_instruction_value in enum_type.range:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if description.close_instruction_value in enum_type.range:
|
||||
self._attr_supported_features |= CoverEntityFeature.CLOSE
|
||||
if description.stop_instruction_value in enum_type.range:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
self._current_state = get_dpcode(self.device, description.current_state)
|
||||
if instruction_wrapper:
|
||||
if instruction_wrapper.get_open_command(device) is not None:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if instruction_wrapper.get_close_command(device) is not None:
|
||||
self._attr_supported_features |= CoverEntityFeature.CLOSE
|
||||
if instruction_wrapper.get_stop_command(device) is not None:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
if set_position:
|
||||
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
|
||||
@@ -309,72 +406,47 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
if (position := self.current_cover_position) is not None:
|
||||
return position == 0
|
||||
|
||||
if (
|
||||
self._current_state is not None
|
||||
and (current_state := self.device.status.get(self._current_state))
|
||||
is not None
|
||||
and current_state != "stop"
|
||||
):
|
||||
return self.entity_description.current_state_inverse is not (
|
||||
current_state in (True, "close", "fully_close")
|
||||
)
|
||||
if self._current_state_wrapper:
|
||||
return self._current_state_wrapper.is_closed(self.device)
|
||||
|
||||
return None
|
||||
|
||||
def open_cover(self, **kwargs: Any) -> None:
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
value: bool | str = True
|
||||
if find_dpcode(
|
||||
self.device,
|
||||
self.entity_description.key,
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
if self._instruction_wrapper and (
|
||||
command := self._instruction_wrapper.get_open_command(self.device)
|
||||
):
|
||||
value = self.entity_description.open_instruction_value
|
||||
|
||||
commands: list[dict[str, str | int]] = [
|
||||
{"code": self.entity_description.key, "value": value}
|
||||
]
|
||||
await self._async_send_commands([command])
|
||||
return
|
||||
|
||||
if self._set_position is not None:
|
||||
commands.append(self._set_position.get_update_command(self.device, 100))
|
||||
await self._async_send_commands(
|
||||
[self._set_position.get_update_command(self.device, 100)]
|
||||
)
|
||||
|
||||
self._send_command(commands)
|
||||
|
||||
def close_cover(self, **kwargs: Any) -> None:
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
value: bool | str = False
|
||||
if find_dpcode(
|
||||
self.device,
|
||||
self.entity_description.key,
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
if self._instruction_wrapper and (
|
||||
command := self._instruction_wrapper.get_close_command(self.device)
|
||||
):
|
||||
value = self.entity_description.close_instruction_value
|
||||
|
||||
commands: list[dict[str, str | int]] = [
|
||||
{"code": self.entity_description.key, "value": value}
|
||||
]
|
||||
await self._async_send_commands([command])
|
||||
return
|
||||
|
||||
if self._set_position is not None:
|
||||
commands.append(self._set_position.get_update_command(self.device, 0))
|
||||
|
||||
self._send_command(commands)
|
||||
await self._async_send_commands(
|
||||
[self._set_position.get_update_command(self.device, 0)]
|
||||
)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION])
|
||||
|
||||
def stop_cover(self, **kwargs: Any) -> None:
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
self._send_command(
|
||||
[
|
||||
{
|
||||
"code": self.entity_description.key,
|
||||
"value": self.entity_description.stop_instruction_value,
|
||||
}
|
||||
]
|
||||
)
|
||||
if self._instruction_wrapper and (
|
||||
command := self._instruction_wrapper.get_stop_command(self.device)
|
||||
):
|
||||
await self._async_send_commands([command])
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
|
||||
@@ -66,6 +66,10 @@ class TuyaEntity(Entity):
|
||||
LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands)
|
||||
self.device_manager.send_commands(self.device.id, commands)
|
||||
|
||||
async def _async_send_commands(self, commands: list[dict[str, Any]]) -> None:
|
||||
"""Send a list of commands to the device."""
|
||||
await self.hass.async_add_executor_job(self._send_command, commands)
|
||||
|
||||
def _read_wrapper(self, dpcode_wrapper: DPCodeWrapper | None) -> Any | None:
|
||||
"""Read the wrapper device status."""
|
||||
if dpcode_wrapper is None:
|
||||
|
||||
@@ -21,12 +21,13 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import EnumTypeData, IntegerTypeData, find_dpcode
|
||||
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper
|
||||
from .util import get_dpcode
|
||||
|
||||
_DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,)
|
||||
_MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE)
|
||||
_OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL)
|
||||
_SPEED_DPCODES = (
|
||||
DPCode.FAN_SPEED_PERCENT,
|
||||
@@ -46,6 +47,19 @@ TUYA_SUPPORT_TYPE: set[DeviceCategory] = {
|
||||
}
|
||||
|
||||
|
||||
class _DirectionEnumWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for fan direction DP code."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device status and return the direction string."""
|
||||
if (value := super().read_device_status(device)) and value in {
|
||||
DIRECTION_FORWARD,
|
||||
DIRECTION_REVERSE,
|
||||
}:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
|
||||
"""Check if the device has at least one valid DP code."""
|
||||
properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [
|
||||
@@ -59,6 +73,55 @@ def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
|
||||
return any(get_dpcode(device, code) for code in properties_to_check)
|
||||
|
||||
|
||||
class _FanSpeedEnumWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for fan speed DP code (from an enum)."""
|
||||
|
||||
def get_speed_count(self) -> int:
|
||||
"""Get the number of speeds supported by the fan."""
|
||||
return len(self.type_information.range)
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None: # type: ignore[override]
|
||||
"""Get the current speed as a percentage."""
|
||||
if (value := super().read_device_status(device)) is None:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(self.type_information.range, value)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
return percentage_to_ordered_list_item(self.type_information.range, value)
|
||||
|
||||
|
||||
class _FanSpeedIntegerWrapper(DPCodeIntegerWrapper):
|
||||
"""Wrapper for fan speed DP code (from an integer)."""
|
||||
|
||||
def get_speed_count(self) -> int:
|
||||
"""Get the number of speeds supported by the fan."""
|
||||
return 100
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Get the current speed as a percentage."""
|
||||
if (value := super().read_device_status(device)) is None:
|
||||
return None
|
||||
return round(self.type_information.remap_value_to(value, 1, 100))
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
return round(self.type_information.remap_value_from(value, 1, 100))
|
||||
|
||||
|
||||
def _get_speed_wrapper(
|
||||
device: CustomerDevice,
|
||||
) -> _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | None:
|
||||
"""Get the speed wrapper for the device."""
|
||||
if int_wrapper := _FanSpeedIntegerWrapper.find_dpcode(
|
||||
device, _SPEED_DPCODES, prefer_function=True
|
||||
):
|
||||
return int_wrapper
|
||||
return _FanSpeedEnumWrapper.find_dpcode(
|
||||
device, _SPEED_DPCODES, prefer_function=True
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -74,7 +137,25 @@ async def async_setup_entry(
|
||||
for device_id in device_ids:
|
||||
device = manager.device_map[device_id]
|
||||
if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device):
|
||||
entities.append(TuyaFanEntity(device, manager))
|
||||
entities.append(
|
||||
TuyaFanEntity(
|
||||
device,
|
||||
manager,
|
||||
direction_wrapper=_DirectionEnumWrapper.find_dpcode(
|
||||
device, _DIRECTION_DPCODES, prefer_function=True
|
||||
),
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, _MODE_DPCODES, prefer_function=True
|
||||
),
|
||||
oscillate_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, _OSCILLATE_DPCODES, prefer_function=True
|
||||
),
|
||||
speed_wrapper=_get_speed_wrapper(device),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, _SWITCH_DPCODES, prefer_function=True
|
||||
),
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
async_discover_device([*manager.device_map])
|
||||
@@ -87,203 +168,111 @@ async def async_setup_entry(
|
||||
class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
"""Tuya Fan Device."""
|
||||
|
||||
_direction: EnumTypeData | None = None
|
||||
_oscillate: DPCode | None = None
|
||||
_presets: EnumTypeData | None = None
|
||||
_speed: IntegerTypeData | None = None
|
||||
_speeds: EnumTypeData | None = None
|
||||
_switch: DPCode | None = None
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
*,
|
||||
direction_wrapper: _DirectionEnumWrapper | None,
|
||||
mode_wrapper: DPCodeEnumWrapper | None,
|
||||
oscillate_wrapper: DPCodeBooleanWrapper | None,
|
||||
speed_wrapper: _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya Fan Device."""
|
||||
super().__init__(device, device_manager)
|
||||
self._direction_wrapper = direction_wrapper
|
||||
self._mode_wrapper = mode_wrapper
|
||||
self._oscillate_wrapper = oscillate_wrapper
|
||||
self._speed_wrapper = speed_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
self._switch = get_dpcode(self.device, _SWITCH_DPCODES)
|
||||
|
||||
self._attr_preset_modes = []
|
||||
if enum_type := find_dpcode(
|
||||
self.device,
|
||||
(DPCode.FAN_MODE, DPCode.MODE),
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
):
|
||||
self._presets = enum_type
|
||||
if mode_wrapper:
|
||||
self._attr_supported_features |= FanEntityFeature.PRESET_MODE
|
||||
self._attr_preset_modes = enum_type.range
|
||||
self._attr_preset_modes = mode_wrapper.type_information.range
|
||||
|
||||
# Find speed controls, can be either percentage or a set of speeds
|
||||
if int_type := find_dpcode(
|
||||
self.device, _SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True
|
||||
):
|
||||
if speed_wrapper:
|
||||
self._attr_supported_features |= FanEntityFeature.SET_SPEED
|
||||
self._speed = int_type
|
||||
elif enum_type := find_dpcode(
|
||||
self.device, _SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
self._attr_supported_features |= FanEntityFeature.SET_SPEED
|
||||
self._speeds = enum_type
|
||||
self._attr_speed_count = speed_wrapper.get_speed_count()
|
||||
|
||||
if dpcode := get_dpcode(self.device, _OSCILLATE_DPCODES):
|
||||
self._oscillate = dpcode
|
||||
if oscillate_wrapper:
|
||||
self._attr_supported_features |= FanEntityFeature.OSCILLATE
|
||||
|
||||
if enum_type := find_dpcode(
|
||||
self.device, _DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
self._direction = enum_type
|
||||
if direction_wrapper:
|
||||
self._attr_supported_features |= FanEntityFeature.DIRECTION
|
||||
if self._switch is not None:
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode of the fan."""
|
||||
if self._presets is None:
|
||||
return
|
||||
self._send_command([{"code": self._presets.dpcode, "value": preset_mode}])
|
||||
await self._async_send_dpcode_update(self._mode_wrapper, preset_mode)
|
||||
|
||||
def set_direction(self, direction: str) -> None:
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Set the direction of the fan."""
|
||||
if self._direction is None:
|
||||
return
|
||||
self._send_command([{"code": self._direction.dpcode, "value": direction}])
|
||||
await self._async_send_dpcode_update(self._direction_wrapper, direction)
|
||||
|
||||
def set_percentage(self, percentage: int) -> None:
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
if self._speed is not None:
|
||||
self._send_command(
|
||||
[
|
||||
{
|
||||
"code": self._speed.dpcode,
|
||||
"value": int(self._speed.remap_value_from(percentage, 1, 100)),
|
||||
}
|
||||
]
|
||||
)
|
||||
return
|
||||
await self._async_send_dpcode_update(self._speed_wrapper, percentage)
|
||||
|
||||
if self._speeds is not None:
|
||||
self._send_command(
|
||||
[
|
||||
{
|
||||
"code": self._speeds.dpcode,
|
||||
"value": percentage_to_ordered_list_item(
|
||||
self._speeds.range, percentage
|
||||
),
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
self._send_command([{"code": self._switch, "value": False}])
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
|
||||
def turn_on(
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if self._switch is None:
|
||||
if self._switch_wrapper is None:
|
||||
return
|
||||
|
||||
commands: list[dict[str, str | bool | int]] = [
|
||||
{"code": self._switch, "value": True}
|
||||
self._switch_wrapper.get_update_command(self.device, True)
|
||||
]
|
||||
|
||||
if percentage is not None and self._speed is not None:
|
||||
if percentage is not None and self._speed_wrapper is not None:
|
||||
commands.append(
|
||||
{
|
||||
"code": self._speed.dpcode,
|
||||
"value": int(self._speed.remap_value_from(percentage, 1, 100)),
|
||||
}
|
||||
self._speed_wrapper.get_update_command(self.device, percentage)
|
||||
)
|
||||
|
||||
if percentage is not None and self._speeds is not None:
|
||||
if preset_mode is not None and self._mode_wrapper:
|
||||
commands.append(
|
||||
{
|
||||
"code": self._speeds.dpcode,
|
||||
"value": percentage_to_ordered_list_item(
|
||||
self._speeds.range, percentage
|
||||
),
|
||||
}
|
||||
self._mode_wrapper.get_update_command(self.device, preset_mode)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
if preset_mode is not None and self._presets is not None:
|
||||
commands.append({"code": self._presets.dpcode, "value": preset_mode})
|
||||
|
||||
self._send_command(commands)
|
||||
|
||||
def oscillate(self, oscillating: bool) -> None:
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Oscillate the fan."""
|
||||
if self._oscillate is None:
|
||||
return
|
||||
self._send_command([{"code": self._oscillate, "value": oscillating}])
|
||||
await self._async_send_dpcode_update(self._oscillate_wrapper, oscillating)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if fan is on."""
|
||||
if self._switch is None:
|
||||
return None
|
||||
return self.device.status.get(self._switch)
|
||||
return self._read_wrapper(self._switch_wrapper)
|
||||
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
"""Return the current direction of the fan."""
|
||||
if (
|
||||
self._direction is None
|
||||
or (value := self.device.status.get(self._direction.dpcode)) is None
|
||||
):
|
||||
return None
|
||||
|
||||
if value.lower() == DIRECTION_FORWARD:
|
||||
return DIRECTION_FORWARD
|
||||
|
||||
if value.lower() == DIRECTION_REVERSE:
|
||||
return DIRECTION_REVERSE
|
||||
|
||||
return None
|
||||
return self._read_wrapper(self._direction_wrapper)
|
||||
|
||||
@property
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return true if the fan is oscillating."""
|
||||
if self._oscillate is None:
|
||||
return None
|
||||
return self.device.status.get(self._oscillate)
|
||||
return self._read_wrapper(self._oscillate_wrapper)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset_mode."""
|
||||
if self._presets is None:
|
||||
return None
|
||||
return self.device.status.get(self._presets.dpcode)
|
||||
return self._read_wrapper(self._mode_wrapper)
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed."""
|
||||
if self._speed is not None:
|
||||
if (value := self.device.status.get(self._speed.dpcode)) is None:
|
||||
return None
|
||||
return int(self._speed.remap_value_to(value, 1, 100))
|
||||
|
||||
if self._speeds is not None:
|
||||
if (
|
||||
value := self.device.status.get(self._speeds.dpcode)
|
||||
) is None or value not in self._speeds.range:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(self._speeds.range, value)
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
if self._speeds is not None:
|
||||
return len(self._speeds.range)
|
||||
return 100
|
||||
return self._read_wrapper(self._speed_wrapper)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -27,15 +28,16 @@ from homeassistant.util import color as color_util
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode
|
||||
from .entity import TuyaEntity
|
||||
from .models import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
IntegerTypeData,
|
||||
)
|
||||
from .util import get_dpcode, get_dptype, remap_value
|
||||
from .util import remap_value
|
||||
|
||||
|
||||
class _BrightnessWrapper(DPCodeIntegerWrapper):
|
||||
@@ -136,43 +138,83 @@ class _ColorTempWrapper(DPCodeIntegerWrapper):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorTypeData:
|
||||
"""Color Type Data."""
|
||||
|
||||
h_type: IntegerTypeData
|
||||
s_type: IntegerTypeData
|
||||
v_type: IntegerTypeData
|
||||
|
||||
|
||||
DEFAULT_COLOR_TYPE_DATA = ColorTypeData(
|
||||
h_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
),
|
||||
s_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
),
|
||||
v_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
),
|
||||
DEFAULT_H_TYPE = IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
)
|
||||
DEFAULT_S_TYPE = IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
)
|
||||
DEFAULT_V_TYPE = IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
)
|
||||
|
||||
DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData(
|
||||
h_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
),
|
||||
s_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
),
|
||||
v_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
),
|
||||
|
||||
DEFAULT_H_TYPE_V2 = IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
)
|
||||
DEFAULT_S_TYPE_V2 = IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
)
|
||||
DEFAULT_V_TYPE_V2 = IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
)
|
||||
|
||||
|
||||
class _ColorDataWrapper(DPCodeJsonWrapper):
|
||||
"""Wrapper for color data DP code."""
|
||||
|
||||
h_type = DEFAULT_H_TYPE
|
||||
s_type = DEFAULT_S_TYPE
|
||||
v_type = DEFAULT_V_TYPE
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
"""Read the color data for the dpcode."""
|
||||
if (status_data := self._read_device_status_raw(device)) is None or not (
|
||||
status := json_loads_object(status_data)
|
||||
):
|
||||
return None
|
||||
return status
|
||||
|
||||
def read_hs_color(self, device: CustomerDevice) -> tuple[float, float] | None:
|
||||
"""Get the HS value from this color data."""
|
||||
if (status := self.read_device_status(device)) is None:
|
||||
return None
|
||||
return (
|
||||
self.h_type.remap_value_to(cast(int, status["h"]), 0, 360),
|
||||
self.s_type.remap_value_to(cast(int, status["s"]), 0, 100),
|
||||
)
|
||||
|
||||
def read_brightness(self, device: CustomerDevice) -> int | None:
|
||||
"""Get the brightness value from this color data."""
|
||||
if (status := self.read_device_status(device)) is None:
|
||||
return None
|
||||
return round(self.v_type.remap_value_to(cast(int, status["v"]), 0, 255))
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant color/brightness pair back to a raw device value."""
|
||||
color, brightness = value
|
||||
return json.dumps(
|
||||
{
|
||||
"h": round(self.h_type.remap_value_from(color[0], 0, 360)),
|
||||
"s": round(self.s_type.remap_value_from(color[1], 0, 100)),
|
||||
"v": round(self.v_type.remap_value_from(brightness)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
MAX_MIREDS = 500 # 2000 K
|
||||
MIN_MIREDS = 153 # 6500 K
|
||||
|
||||
|
||||
class FallbackColorDataMode(StrEnum):
|
||||
"""Fallback color data mode."""
|
||||
|
||||
V1 = "v1"
|
||||
"""hue: 0-360, saturation: 0-255, value: 0-255"""
|
||||
V2 = "v2"
|
||||
"""hue: 0-360, saturation: 0-1000, value: 0-1000"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaLightEntityDescription(LightEntityDescription):
|
||||
"""Describe an Tuya light entity."""
|
||||
@@ -183,9 +225,7 @@ class TuyaLightEntityDescription(LightEntityDescription):
|
||||
color_data: DPCode | tuple[DPCode, ...] | None = None
|
||||
color_mode: DPCode | None = None
|
||||
color_temp: DPCode | tuple[DPCode, ...] | None = None
|
||||
default_color_type: ColorTypeData = field(
|
||||
default_factory=lambda: DEFAULT_COLOR_TYPE_DATA
|
||||
)
|
||||
fallback_color_data_mode: FallbackColorDataMode = FallbackColorDataMode.V1
|
||||
|
||||
|
||||
LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
|
||||
@@ -222,7 +262,7 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
|
||||
brightness=DPCode.BRIGHT_VALUE,
|
||||
color_temp=DPCode.TEMP_VALUE,
|
||||
color_data=DPCode.COLOUR_DATA,
|
||||
default_color_type=DEFAULT_COLOR_TYPE_DATA_V2,
|
||||
fallback_color_data_mode=FallbackColorDataMode.V2,
|
||||
),
|
||||
),
|
||||
DeviceCategory.DJ: (
|
||||
@@ -504,29 +544,6 @@ LIGHTS[DeviceCategory.DGHSXJ] = LIGHTS[DeviceCategory.SP]
|
||||
LIGHTS[DeviceCategory.TDQ] = LIGHTS[DeviceCategory.TGQ]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorData:
|
||||
"""Color Data."""
|
||||
|
||||
type_data: ColorTypeData
|
||||
h_value: int
|
||||
s_value: int
|
||||
v_value: int
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float]:
|
||||
"""Get the HS value from this color data."""
|
||||
return (
|
||||
self.type_data.h_type.remap_value_to(self.h_value, 0, 360),
|
||||
self.type_data.s_type.remap_value_to(self.s_value, 0, 100),
|
||||
)
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Get the brightness value from this color data."""
|
||||
return round(self.type_data.v_type.remap_value_to(self.v_value, 0, 255))
|
||||
|
||||
|
||||
def _get_brightness_wrapper(
|
||||
device: CustomerDevice, description: TuyaLightEntityDescription
|
||||
) -> _BrightnessWrapper | None:
|
||||
@@ -545,6 +562,46 @@ def _get_brightness_wrapper(
|
||||
return brightness_wrapper
|
||||
|
||||
|
||||
def _get_color_data_wrapper(
|
||||
device: CustomerDevice,
|
||||
description: TuyaLightEntityDescription,
|
||||
brightness_wrapper: _BrightnessWrapper | None,
|
||||
) -> _ColorDataWrapper | None:
|
||||
if (
|
||||
color_data_wrapper := _ColorDataWrapper.find_dpcode(
|
||||
device, description.color_data, prefer_function=True
|
||||
)
|
||||
) is None:
|
||||
return None
|
||||
|
||||
# Fetch color data type information
|
||||
if function_data := json_loads_object(
|
||||
cast(str, color_data_wrapper.type_information.type_data)
|
||||
):
|
||||
color_data_wrapper.h_type = IntegerTypeData(
|
||||
dpcode=color_data_wrapper.dpcode,
|
||||
**cast(dict, function_data["h"]),
|
||||
)
|
||||
color_data_wrapper.s_type = IntegerTypeData(
|
||||
dpcode=color_data_wrapper.dpcode,
|
||||
**cast(dict, function_data["s"]),
|
||||
)
|
||||
color_data_wrapper.v_type = IntegerTypeData(
|
||||
dpcode=color_data_wrapper.dpcode,
|
||||
**cast(dict, function_data["v"]),
|
||||
)
|
||||
elif (
|
||||
description.fallback_color_data_mode == FallbackColorDataMode.V2
|
||||
or color_data_wrapper.dpcode == DPCode.COLOUR_DATA_V2
|
||||
or (brightness_wrapper and brightness_wrapper.type_information.max > 255)
|
||||
):
|
||||
color_data_wrapper.h_type = DEFAULT_H_TYPE_V2
|
||||
color_data_wrapper.s_type = DEFAULT_S_TYPE_V2
|
||||
color_data_wrapper.v_type = DEFAULT_V_TYPE_V2
|
||||
|
||||
return color_data_wrapper
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -565,7 +622,14 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
brightness_wrapper=_get_brightness_wrapper(device, description),
|
||||
brightness_wrapper=(
|
||||
brightness_wrapper := _get_brightness_wrapper(
|
||||
device, description
|
||||
)
|
||||
),
|
||||
color_data_wrapper=_get_color_data_wrapper(
|
||||
device, description, brightness_wrapper
|
||||
),
|
||||
color_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.color_mode, prefer_function=True
|
||||
),
|
||||
@@ -596,8 +660,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
|
||||
entity_description: TuyaLightEntityDescription
|
||||
|
||||
_color_data_dpcode: DPCode | None = None
|
||||
_color_data_type: ColorTypeData | None = None
|
||||
_white_color_mode = ColorMode.COLOR_TEMP
|
||||
_fixed_color_mode: ColorMode | None = None
|
||||
_attr_min_color_temp_kelvin = 2000 # 500 Mireds
|
||||
@@ -610,6 +672,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
description: TuyaLightEntityDescription,
|
||||
*,
|
||||
brightness_wrapper: _BrightnessWrapper | None,
|
||||
color_data_wrapper: _ColorDataWrapper | None,
|
||||
color_mode_wrapper: DPCodeEnumWrapper | None,
|
||||
color_temp_wrapper: _ColorTempWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper,
|
||||
@@ -619,6 +682,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._brightness_wrapper = brightness_wrapper
|
||||
self._color_data_wrapper = color_data_wrapper
|
||||
self._color_mode_wrapper = color_mode_wrapper
|
||||
self._color_temp_wrapper = color_temp_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
@@ -628,37 +692,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
if brightness_wrapper:
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
|
||||
if (dpcode := get_dpcode(self.device, description.color_data)) and (
|
||||
get_dptype(self.device, dpcode, prefer_function=True) == DPType.JSON
|
||||
):
|
||||
self._color_data_dpcode = dpcode
|
||||
if color_data_wrapper:
|
||||
color_modes.add(ColorMode.HS)
|
||||
if dpcode in self.device.function:
|
||||
values = cast(str, self.device.function[dpcode].values)
|
||||
else:
|
||||
values = self.device.status_range[dpcode].values
|
||||
|
||||
# Fetch color data type information
|
||||
if function_data := json_loads_object(values):
|
||||
self._color_data_type = ColorTypeData(
|
||||
h_type=IntegerTypeData(
|
||||
dpcode=dpcode, **cast(dict, function_data["h"])
|
||||
),
|
||||
s_type=IntegerTypeData(
|
||||
dpcode=dpcode, **cast(dict, function_data["s"])
|
||||
),
|
||||
v_type=IntegerTypeData(
|
||||
dpcode=dpcode, **cast(dict, function_data["v"])
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no type is found, use a default one
|
||||
self._color_data_type = self.entity_description.default_color_type
|
||||
if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or (
|
||||
self._brightness_wrapper
|
||||
and self._brightness_wrapper.type_information.max > 255
|
||||
):
|
||||
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
|
||||
|
||||
# Check if the light has color temperature
|
||||
if color_temp_wrapper:
|
||||
@@ -705,7 +740,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
)
|
||||
]
|
||||
|
||||
if self._color_data_type and (
|
||||
if self._color_data_wrapper and (
|
||||
ATTR_HS_COLOR in kwargs
|
||||
or (
|
||||
ATTR_BRIGHTNESS in kwargs
|
||||
@@ -728,28 +763,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
color = self.hs_color or (0, 0)
|
||||
|
||||
commands += [
|
||||
{
|
||||
"code": self._color_data_dpcode,
|
||||
"value": json.dumps(
|
||||
{
|
||||
"h": round(
|
||||
self._color_data_type.h_type.remap_value_from(
|
||||
color[0], 0, 360
|
||||
)
|
||||
),
|
||||
"s": round(
|
||||
self._color_data_type.s_type.remap_value_from(
|
||||
color[1], 0, 100
|
||||
)
|
||||
),
|
||||
"v": round(
|
||||
self._color_data_type.v_type.remap_value_from(
|
||||
brightness
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
self._color_data_wrapper.get_update_command(
|
||||
self.device, (color, brightness)
|
||||
),
|
||||
]
|
||||
|
||||
elif self._brightness_wrapper and (
|
||||
@@ -774,8 +790,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
# If the light is currently in color mode, extract the brightness from the color data
|
||||
if self.color_mode == ColorMode.HS and (color_data := self._get_color_data()):
|
||||
return color_data.brightness
|
||||
if self.color_mode == ColorMode.HS and self._color_data_wrapper:
|
||||
return self._color_data_wrapper.read_brightness(self.device)
|
||||
|
||||
return self._read_wrapper(self._brightness_wrapper)
|
||||
|
||||
@@ -787,11 +803,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the hs_color of the light."""
|
||||
if self._color_data_dpcode is None or not (
|
||||
color_data := self._get_color_data()
|
||||
):
|
||||
if self._color_data_wrapper is None:
|
||||
return None
|
||||
return color_data.hs_color
|
||||
return self._color_data_wrapper.read_hs_color(self.device)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
@@ -809,25 +823,3 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
):
|
||||
return ColorMode.HS
|
||||
return self._white_color_mode
|
||||
|
||||
def _get_color_data(self) -> ColorData | None:
|
||||
"""Get current color data from device."""
|
||||
if (
|
||||
self._color_data_type is None
|
||||
or self._color_data_dpcode is None
|
||||
or self._color_data_dpcode not in self.device.status
|
||||
):
|
||||
return None
|
||||
|
||||
if not (status_data := self.device.status[self._color_data_dpcode]):
|
||||
return None
|
||||
|
||||
if not (status := json_loads_object(status_data)):
|
||||
return None
|
||||
|
||||
return ColorData(
|
||||
type_data=self._color_data_type,
|
||||
h_value=cast(int, status["h"]),
|
||||
s_value=cast(int, status["s"]),
|
||||
v_value=cast(int, status["v"]),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper
|
||||
from .util import get_dpcode
|
||||
|
||||
TUYA_MODE_RETURN_HOME = "chargego"
|
||||
TUYA_STATUS_TO_HA = {
|
||||
@@ -80,6 +79,12 @@ async def async_setup_entry(
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
pause_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.PAUSE
|
||||
),
|
||||
status_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.STATUS
|
||||
),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.POWER_GO, prefer_function=True
|
||||
),
|
||||
@@ -108,6 +113,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
fan_speed_wrapper: DPCodeEnumWrapper | None,
|
||||
locate_wrapper: DPCodeBooleanWrapper | None,
|
||||
mode_wrapper: DPCodeEnumWrapper | None,
|
||||
pause_wrapper: DPCodeBooleanWrapper | None,
|
||||
status_wrapper: DPCodeEnumWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya vacuum."""
|
||||
@@ -116,13 +123,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._fan_speed_wrapper = fan_speed_wrapper
|
||||
self._locate_wrapper = locate_wrapper
|
||||
self._mode_wrapper = mode_wrapper
|
||||
self._pause_wrapper = pause_wrapper
|
||||
self._status_wrapper = status_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
self._attr_fan_speed_list = []
|
||||
self._attr_supported_features = (
|
||||
VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE
|
||||
)
|
||||
if get_dpcode(self.device, DPCode.PAUSE):
|
||||
if pause_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
|
||||
if charge_wrapper or (
|
||||
@@ -151,13 +160,12 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
@property
|
||||
def activity(self) -> VacuumActivity | None:
|
||||
"""Return Tuya vacuum device state."""
|
||||
if self.device.status.get(DPCode.PAUSE) and not (
|
||||
self.device.status.get(DPCode.STATUS)
|
||||
):
|
||||
if (status := self._read_wrapper(self._status_wrapper)) is not None:
|
||||
return TUYA_STATUS_TO_HA.get(status)
|
||||
|
||||
if self._read_wrapper(self._pause_wrapper):
|
||||
return VacuumActivity.PAUSED
|
||||
if not (status := self.device.status.get(DPCode.STATUS)):
|
||||
return None
|
||||
return TUYA_STATUS_TO_HA.get(status)
|
||||
return None
|
||||
|
||||
async def async_start(self, **kwargs: Any) -> None:
|
||||
"""Start the device."""
|
||||
@@ -167,9 +175,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
"""Stop the device."""
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
|
||||
def pause(self, **kwargs: Any) -> None:
|
||||
async def async_pause(self, **kwargs: Any) -> None:
|
||||
"""Pause the device."""
|
||||
self._send_command([{"code": DPCode.POWER_GO, "value": False}])
|
||||
await self.async_stop(**kwargs)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Return device to dock."""
|
||||
|
||||
@@ -6,15 +6,15 @@ from wallbox import Wallbox
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from .const import UPDATE_INTERVAL
|
||||
from .coordinator import (
|
||||
InvalidAuth,
|
||||
WallboxConfigEntry,
|
||||
WallboxCoordinator,
|
||||
async_validate_input,
|
||||
from .const import (
|
||||
CHARGER_JWT_REFRESH_TOKEN,
|
||||
CHARGER_JWT_REFRESH_TTL,
|
||||
CHARGER_JWT_TOKEN,
|
||||
CHARGER_JWT_TTL,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LOCK,
|
||||
@@ -32,10 +32,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> b
|
||||
entry.data[CONF_PASSWORD],
|
||||
jwtTokenDrift=UPDATE_INTERVAL,
|
||||
)
|
||||
try:
|
||||
await async_validate_input(hass, wallbox)
|
||||
except InvalidAuth as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
|
||||
if CHARGER_JWT_TOKEN in entry.data and check_token_validity(
|
||||
jwt_token_ttl=entry.data.get(CHARGER_JWT_TTL, 0),
|
||||
jwt_token_drift=UPDATE_INTERVAL,
|
||||
):
|
||||
wallbox.jwtToken = entry.data.get(CHARGER_JWT_TOKEN)
|
||||
wallbox.jwtRefreshToken = entry.data.get(CHARGER_JWT_REFRESH_TOKEN)
|
||||
wallbox.jwtTokenTtl = entry.data.get(CHARGER_JWT_TTL)
|
||||
wallbox.jwtRefreshTokenTtl = entry.data.get(CHARGER_JWT_REFRESH_TTL)
|
||||
wallbox.headers["Authorization"] = f"Bearer {entry.data.get(CHARGER_JWT_TOKEN)}"
|
||||
|
||||
wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox)
|
||||
await wallbox_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -12,7 +12,15 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowRe
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_STATION, DOMAIN
|
||||
from .const import (
|
||||
CHARGER_JWT_REFRESH_TOKEN,
|
||||
CHARGER_JWT_REFRESH_TTL,
|
||||
CHARGER_JWT_TOKEN,
|
||||
CHARGER_JWT_TTL,
|
||||
CONF_STATION,
|
||||
DOMAIN,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
from .coordinator import InvalidAuth, async_validate_input
|
||||
|
||||
COMPONENT_DOMAIN = DOMAIN
|
||||
@@ -26,17 +34,22 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
wallbox = Wallbox(data["username"], data["password"])
|
||||
wallbox = Wallbox(data[CONF_USERNAME], data[CONF_PASSWORD], UPDATE_INTERVAL)
|
||||
|
||||
await async_validate_input(hass, wallbox)
|
||||
|
||||
data[CHARGER_JWT_TOKEN] = wallbox.jwtToken
|
||||
data[CHARGER_JWT_REFRESH_TOKEN] = wallbox.jwtRefreshToken
|
||||
data[CHARGER_JWT_TTL] = wallbox.jwtTokenTtl
|
||||
data[CHARGER_JWT_REFRESH_TTL] = wallbox.jwtRefreshTokenTtl
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
return {"title": "Wallbox Portal"}
|
||||
return {"title": "Wallbox Portal", "data": data}
|
||||
|
||||
|
||||
class WallboxConfigFlow(ConfigFlow, domain=COMPONENT_DOMAIN):
|
||||
@@ -64,8 +77,11 @@ class WallboxConfigFlow(ConfigFlow, domain=COMPONENT_DOMAIN):
|
||||
await self.async_set_unique_id(user_input["station"])
|
||||
if self.source != SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_configured()
|
||||
info = await validate_input(self.hass, user_input)
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
validation_data = await validate_input(self.hass, user_input)
|
||||
return self.async_create_entry(
|
||||
title=validation_data["title"],
|
||||
data=validation_data["data"],
|
||||
)
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input["station"] == reauth_entry.data[CONF_STATION]:
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=user_input)
|
||||
|
||||
@@ -47,6 +47,12 @@ CHARGER_CONNECTIONS = "connections"
|
||||
CHARGER_ECO_SMART_KEY = "ecosmart"
|
||||
CHARGER_ECO_SMART_STATUS_KEY = "enabled"
|
||||
CHARGER_ECO_SMART_MODE_KEY = "mode"
|
||||
CHARGER_WALLBOX_OBJECT_KEY = "wallbox"
|
||||
|
||||
CHARGER_JWT_TOKEN = "jwtToken"
|
||||
CHARGER_JWT_REFRESH_TOKEN = "jwtRefreshToken"
|
||||
CHARGER_JWT_TTL = "jwtTokenTtl"
|
||||
CHARGER_JWT_REFRESH_TTL = "jwtRefreshTokenTtl"
|
||||
|
||||
|
||||
class ChargerStatus(StrEnum):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
@@ -27,6 +27,10 @@ from .const import (
|
||||
CHARGER_ECO_SMART_STATUS_KEY,
|
||||
CHARGER_ENERGY_PRICE_KEY,
|
||||
CHARGER_FEATURES_KEY,
|
||||
CHARGER_JWT_REFRESH_TOKEN,
|
||||
CHARGER_JWT_REFRESH_TTL,
|
||||
CHARGER_JWT_TOKEN,
|
||||
CHARGER_JWT_TTL,
|
||||
CHARGER_LOCKED_UNLOCKED_KEY,
|
||||
CHARGER_MAX_CHARGING_CURRENT_KEY,
|
||||
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
|
||||
@@ -86,27 +90,25 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P](
|
||||
) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]:
|
||||
"""Authenticate with decorator using Wallbox API."""
|
||||
|
||||
def require_authentication(
|
||||
async def require_authentication(
|
||||
self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> Any:
|
||||
"""Authenticate using Wallbox API."""
|
||||
try:
|
||||
self.authenticate()
|
||||
return func(self, *args, **kwargs)
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from wallbox_connection_error
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
await self.async_authenticate()
|
||||
return await func(self, *args, **kwargs)
|
||||
|
||||
return require_authentication
|
||||
|
||||
|
||||
def check_token_validity(jwt_token_ttl: int, jwt_token_drift: int) -> bool:
|
||||
"""Check if the jwtToken is still valid in order to reuse if possible."""
|
||||
return round((jwt_token_ttl / 1000) - jwt_token_drift, 0) > datetime.timestamp(
|
||||
datetime.now()
|
||||
)
|
||||
|
||||
|
||||
def _validate(wallbox: Wallbox) -> None:
|
||||
"""Authenticate using Wallbox API."""
|
||||
"""Authenticate using Wallbox API to check if the used credentials are valid."""
|
||||
try:
|
||||
wallbox.authenticate()
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
@@ -142,11 +144,38 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
|
||||
def authenticate(self) -> None:
|
||||
"""Authenticate using Wallbox API."""
|
||||
self._wallbox.authenticate()
|
||||
def _authenticate(self) -> dict[str, str]:
|
||||
"""Authenticate using Wallbox API. First check token validity."""
|
||||
data = dict(self.config_entry.data)
|
||||
if not check_token_validity(
|
||||
jwt_token_ttl=data.get(CHARGER_JWT_TTL, 0),
|
||||
jwt_token_drift=UPDATE_INTERVAL,
|
||||
):
|
||||
try:
|
||||
self._wallbox.authenticate()
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if (
|
||||
wallbox_connection_error.response.status_code
|
||||
== HTTPStatus.FORBIDDEN
|
||||
):
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from wallbox_connection_error
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
else:
|
||||
data[CHARGER_JWT_TOKEN] = self._wallbox.jwtToken
|
||||
data[CHARGER_JWT_REFRESH_TOKEN] = self._wallbox.jwtRefreshToken
|
||||
data[CHARGER_JWT_TTL] = self._wallbox.jwtTokenTtl
|
||||
data[CHARGER_JWT_REFRESH_TTL] = self._wallbox.jwtRefreshTokenTtl
|
||||
return data
|
||||
|
||||
async def async_authenticate(self) -> None:
|
||||
"""Authenticate using Wallbox API."""
|
||||
data = await self.hass.async_add_executor_job(self._authenticate)
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
|
||||
|
||||
@_require_authentication
|
||||
def _get_data(self) -> dict[str, Any]:
|
||||
"""Get new sensor data for Wallbox component."""
|
||||
try:
|
||||
@@ -208,6 +237,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations."""
|
||||
|
||||
@@ -217,7 +247,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
return await self.hass.async_add_executor_job(self._get_data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_charging_current(
|
||||
self, charging_current: float
|
||||
) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
@@ -246,6 +275,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_charging_current(self, charging_current: float) -> None:
|
||||
"""Set maximum charging current for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
@@ -253,7 +283,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_icp_current(self, icp_current: float) -> dict[str, Any]:
|
||||
"""Set maximum icp current for Wallbox."""
|
||||
try:
|
||||
@@ -276,6 +305,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_icp_current(self, icp_current: float) -> None:
|
||||
"""Set maximum icp current for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
@@ -283,7 +313,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]:
|
||||
"""Set energy cost for Wallbox."""
|
||||
try:
|
||||
@@ -300,6 +329,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_energy_cost(self, energy_cost: float) -> None:
|
||||
"""Set energy cost for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
@@ -307,7 +337,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Set wallbox to locked or unlocked."""
|
||||
try:
|
||||
@@ -335,12 +364,12 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_lock_unlock(self, lock: bool) -> None:
|
||||
"""Set wallbox to locked or unlocked."""
|
||||
data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _pause_charger(self, pause: bool) -> None:
|
||||
"""Set wallbox to pause or resume."""
|
||||
try:
|
||||
@@ -357,12 +386,12 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_pause_charger(self, pause: bool) -> None:
|
||||
"""Set wallbox to pause or resume."""
|
||||
await self.hass.async_add_executor_job(self._pause_charger, pause)
|
||||
await self.async_request_refresh()
|
||||
|
||||
@_require_authentication
|
||||
def _set_eco_smart(self, option: str) -> None:
|
||||
"""Set wallbox solar charging mode."""
|
||||
try:
|
||||
@@ -381,6 +410,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_eco_smart(self, option: str) -> None:
|
||||
"""Set wallbox solar charging mode."""
|
||||
|
||||
|
||||
4
homeassistant/generated/bluetooth.py
generated
4
homeassistant/generated/bluetooth.py
generated
@@ -706,6 +706,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "shelly",
|
||||
"local_name": "Shelly*",
|
||||
},
|
||||
{
|
||||
"domain": "shelly",
|
||||
"manufacturer_id": 2985,
|
||||
},
|
||||
{
|
||||
"domain": "snooz",
|
||||
"local_name": "Snooz*",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -264,6 +264,7 @@ FLOWS = {
|
||||
"growatt_server",
|
||||
"guardian",
|
||||
"habitica",
|
||||
"hanna",
|
||||
"harmony",
|
||||
"heos",
|
||||
"here_travel_time",
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"hanna": {
|
||||
"name": "Hanna",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"hardkernel": {
|
||||
"name": "Hardkernel",
|
||||
"integration_type": "hardware",
|
||||
|
||||
14
homeassistant/generated/labs.py
generated
Normal file
14
homeassistant/generated/labs.py
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Automatically generated file.
|
||||
|
||||
To update, run python3 -m script.hassfest
|
||||
"""
|
||||
|
||||
LABS_PREVIEW_FEATURES = {
|
||||
"kitchen_sink": {
|
||||
"special_repair": {
|
||||
"feedback_url": "https://community.home-assistant.io",
|
||||
"learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -266,6 +266,7 @@ class Manifest(TypedDict, total=False):
|
||||
loggers: list[str]
|
||||
import_executor: bool
|
||||
single_config_entry: bool
|
||||
preview_features: dict[str, dict[str, str]]
|
||||
|
||||
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
@@ -900,6 +901,11 @@ class Integration:
|
||||
"""Return Integration bluetooth entries."""
|
||||
return self.manifest.get("bluetooth")
|
||||
|
||||
@property
|
||||
def preview_features(self) -> dict[str, dict[str, str]] | None:
|
||||
"""Return Integration preview features entries."""
|
||||
return self.manifest.get("preview_features")
|
||||
|
||||
@property
|
||||
def dhcp(self) -> list[dict[str, str | bool]] | None:
|
||||
"""Return Integration dhcp entries."""
|
||||
|
||||
@@ -33,7 +33,7 @@ cryptography==46.0.2
|
||||
dbus-fast==3.0.0
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.7.0
|
||||
hass-nabucasa==1.5.1
|
||||
|
||||
9
requirements_all.txt
generated
9
requirements_all.txt
generated
@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
|
||||
PySrDaliGateway==0.16.2
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.73.0
|
||||
PySwitchbot==0.74.0
|
||||
|
||||
# homeassistant.components.switchmate
|
||||
PySwitchmate==0.5.1
|
||||
@@ -389,7 +389,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.18.0
|
||||
aioshelly==13.20.0
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -1049,7 +1049,7 @@ gitterpy==0.1.7
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
@@ -1143,6 +1143,9 @@ habiticalib==0.4.6
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.7.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
astroid==4.0.1
|
||||
coverage==7.10.6
|
||||
freezegun==1.5.2
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
# librt is an internal mypy dependency
|
||||
librt==0.2.1
|
||||
license-expression==30.4.3
|
||||
|
||||
12
requirements_test_all.txt
generated
12
requirements_test_all.txt
generated
@@ -80,7 +80,7 @@ PyRMVtransport==0.3.3
|
||||
PySrDaliGateway==0.16.2
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.73.0
|
||||
PySwitchbot==0.74.0
|
||||
|
||||
# homeassistant.components.syncthru
|
||||
PySyncThru==0.8.0
|
||||
@@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.18.0
|
||||
aioshelly==13.20.0
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -649,6 +649,9 @@ colorthief==0.2.1
|
||||
# homeassistant.components.compit
|
||||
compit-inext-api==0.3.1
|
||||
|
||||
# homeassistant.components.concord232
|
||||
concord232==0.15.1
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
construct==2.10.68
|
||||
|
||||
@@ -916,7 +919,7 @@ gios==6.1.2
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
@@ -1004,6 +1007,9 @@ habiticalib==0.4.6
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.7.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from . import (
|
||||
icons,
|
||||
integration_info,
|
||||
json,
|
||||
labs,
|
||||
manifest,
|
||||
metadata,
|
||||
mqtt,
|
||||
@@ -47,6 +48,7 @@ INTEGRATION_PLUGINS = [
|
||||
icons,
|
||||
integration_info,
|
||||
json,
|
||||
labs,
|
||||
manifest,
|
||||
mqtt,
|
||||
quality_scale,
|
||||
|
||||
@@ -102,6 +102,7 @@ ALLOWED_USED_COMPONENTS = {
|
||||
"input_number",
|
||||
"input_select",
|
||||
"input_text",
|
||||
"labs",
|
||||
"media_source",
|
||||
"onboarding",
|
||||
"panel_custom",
|
||||
@@ -130,6 +131,7 @@ IGNORE_VIOLATIONS = {
|
||||
# This would be a circular dep
|
||||
("http", "network"),
|
||||
("http", "cloud"),
|
||||
("labs", "backup"),
|
||||
# This would be a circular dep
|
||||
("zha", "homeassistant_hardware"),
|
||||
("zha", "homeassistant_sky_connect"),
|
||||
|
||||
2
script/hassfest/docker/Dockerfile
generated
2
script/hassfest/docker/Dockerfile
generated
@@ -29,7 +29,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
|
||||
tqdm==4.67.1 \
|
||||
ruff==0.13.0 \
|
||||
PyTurboJPEG==1.8.0 \
|
||||
go2rtc-client==0.2.1 \
|
||||
go2rtc-client==0.3.0 \
|
||||
ha-ffmpeg==3.2.2 \
|
||||
hassil==3.4.0 \
|
||||
home-assistant-intents==2025.11.7 \
|
||||
|
||||
79
script/hassfest/labs.py
Normal file
79
script/hassfest/labs.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Generate lab preview features file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import Config, Integration
|
||||
from .serializer import format_python_namespace
|
||||
|
||||
|
||||
def generate_and_validate(integrations: dict[str, Integration]) -> str:
|
||||
"""Validate and generate lab preview features data."""
|
||||
labs_dict: dict[str, dict[str, dict[str, str]]] = {}
|
||||
|
||||
for domain in sorted(integrations):
|
||||
integration = integrations[domain]
|
||||
preview_features = integration.manifest.get("preview_features")
|
||||
|
||||
if not preview_features:
|
||||
continue
|
||||
|
||||
if not isinstance(preview_features, dict):
|
||||
integration.add_error(
|
||||
"labs",
|
||||
f"preview_features must be a dict, got {type(preview_features).__name__}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Extract features with full data
|
||||
domain_preview_features: dict[str, dict[str, str]] = {}
|
||||
for preview_feature_id, preview_feature_config in preview_features.items():
|
||||
if not isinstance(preview_feature_id, str):
|
||||
integration.add_error(
|
||||
"labs",
|
||||
f"preview_features keys must be strings, got {type(preview_feature_id).__name__}",
|
||||
)
|
||||
break
|
||||
if not isinstance(preview_feature_config, dict):
|
||||
integration.add_error(
|
||||
"labs",
|
||||
f"preview_features[{preview_feature_id}] must be a dict, got {type(preview_feature_config).__name__}",
|
||||
)
|
||||
break
|
||||
# Include the full feature configuration
|
||||
domain_preview_features[preview_feature_id] = {
|
||||
"feedback_url": preview_feature_config.get("feedback_url", ""),
|
||||
"learn_more_url": preview_feature_config.get("learn_more_url", ""),
|
||||
"report_issue_url": preview_feature_config.get("report_issue_url", ""),
|
||||
}
|
||||
else:
|
||||
# Only add if all features are valid
|
||||
if domain_preview_features:
|
||||
labs_dict[domain] = domain_preview_features
|
||||
|
||||
return format_python_namespace(
|
||||
{
|
||||
"LABS_PREVIEW_FEATURES": labs_dict,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Validate lab preview features file."""
|
||||
labs_path = config.root / "homeassistant/generated/labs.py"
|
||||
config.cache["labs"] = content = generate_and_validate(integrations)
|
||||
|
||||
if config.specific_integrations:
|
||||
return
|
||||
|
||||
if not labs_path.exists() or labs_path.read_text() != content:
|
||||
config.add_error(
|
||||
"labs",
|
||||
"File labs.py is not up to date. Run python3 -m script.hassfest",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
|
||||
def generate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Generate lab preview features file."""
|
||||
labs_path = config.root / "homeassistant/generated/labs.py"
|
||||
labs_path.write_text(config.cache["labs"])
|
||||
@@ -279,6 +279,17 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
|
||||
vol.Optional("disabled"): str,
|
||||
vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES),
|
||||
vol.Optional("single_config_entry"): bool,
|
||||
vol.Optional("preview_features"): vol.Schema(
|
||||
{
|
||||
cv.slug: vol.Schema(
|
||||
{
|
||||
vol.Optional("feedback_url"): vol.Url(),
|
||||
vol.Optional("learn_more_url"): vol.Url(),
|
||||
vol.Optional("report_issue_url"): vol.Url(),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2189,6 +2189,7 @@ NO_QUALITY_SCALE = [
|
||||
"input_text",
|
||||
"intent_script",
|
||||
"intent",
|
||||
"labs",
|
||||
"logbook",
|
||||
"logger",
|
||||
"lovelace",
|
||||
|
||||
@@ -329,6 +329,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
|
||||
flow_title=UNDEFINED,
|
||||
require_step_title=False,
|
||||
),
|
||||
vol.Optional("preview_features"): cv.schema_with_slug_keys(
|
||||
{
|
||||
vol.Required("name"): translation_value_validator,
|
||||
vol.Required("description"): translation_value_validator,
|
||||
vol.Optional("enable_confirmation"): translation_value_validator,
|
||||
vol.Optional("disable_confirmation"): translation_value_validator,
|
||||
},
|
||||
slug_validator=translation_key_validator,
|
||||
),
|
||||
vol.Optional("selector"): cv.schema_with_slug_keys(
|
||||
{
|
||||
vol.Optional("options"): cv.schema_with_slug_keys(
|
||||
|
||||
@@ -356,6 +356,32 @@
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"preview_features": {
|
||||
"description": "Preview features that can be enabled/disabled by users via the Labs UI.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#preview-features",
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feedback_url": {
|
||||
"description": "URL where users can provide feedback about the feature.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"learn_more_url": {
|
||||
"description": "URL where users can learn more about the feature.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"report_issue_url": {
|
||||
"description": "URL where users can report issues with the feature.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"disabled": {
|
||||
"description": "The reason for the integration being disabled.",
|
||||
"type": "string"
|
||||
|
||||
1
tests/components/concord232/__init__.py
Normal file
1
tests/components/concord232/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Concord232 integration."""
|
||||
33
tests/components/concord232/conftest.py
Normal file
33
tests/components/concord232/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Fixtures for the Concord232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_concord232_client() -> Generator[MagicMock]:
|
||||
"""Mock the concord232 Client for easier testing."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.concord232.alarm_control_panel.concord232_client.Client",
|
||||
autospec=True,
|
||||
) as mock_client_class,
|
||||
patch(
|
||||
"homeassistant.components.concord232.binary_sensor.concord232_client.Client",
|
||||
new=mock_client_class,
|
||||
),
|
||||
):
|
||||
mock_instance = mock_client_class.return_value
|
||||
|
||||
# Set up default return values
|
||||
mock_instance.list_partitions.return_value = [{"arming_level": "Off"}]
|
||||
mock_instance.list_zones.return_value = [
|
||||
{"number": 1, "name": "Zone 1", "state": "Normal"},
|
||||
{"number": 2, "name": "Zone 2", "state": "Normal"},
|
||||
]
|
||||
|
||||
yield mock_instance
|
||||
280
tests/components/concord232/test_alarm_control_panel.py
Normal file
280
tests/components/concord232/test_alarm_control_panel.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Tests for the Concord232 alarm control panel platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
DOMAIN as ALARM_DOMAIN,
|
||||
SERVICE_ALARM_ARM_AWAY,
|
||||
SERVICE_ALARM_ARM_HOME,
|
||||
SERVICE_ALARM_DISARM,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
VALID_CONFIG = {
|
||||
ALARM_DOMAIN: {
|
||||
"platform": "concord232",
|
||||
CONF_HOST: "localhost",
|
||||
CONF_PORT: 5007,
|
||||
CONF_NAME: "Test Alarm",
|
||||
}
|
||||
}
|
||||
|
||||
VALID_CONFIG_WITH_CODE = {
|
||||
ALARM_DOMAIN: {
|
||||
"platform": "concord232",
|
||||
CONF_HOST: "localhost",
|
||||
CONF_PORT: 5007,
|
||||
CONF_NAME: "Test Alarm",
|
||||
CONF_CODE: "1234",
|
||||
}
|
||||
}
|
||||
|
||||
VALID_CONFIG_SILENT_MODE = {
|
||||
ALARM_DOMAIN: {
|
||||
"platform": "concord232",
|
||||
CONF_HOST: "localhost",
|
||||
CONF_PORT: 5007,
|
||||
CONF_NAME: "Test Alarm",
|
||||
CONF_MODE: "silent",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_setup_platform(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test platform setup."""
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("alarm_control_panel.test_alarm")
|
||||
assert state is not None
|
||||
assert state.state == AlarmControlPanelState.DISARMED
|
||||
|
||||
|
||||
async def test_setup_platform_connection_error(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test platform setup with connection error."""
|
||||
mock_concord232_client.list_partitions.side_effect = (
|
||||
requests.exceptions.ConnectionError("Connection failed")
|
||||
)
|
||||
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("alarm_control_panel.test_alarm") is None
|
||||
|
||||
|
||||
async def test_alarm_disarm(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test disarm service."""
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
ALARM_DOMAIN,
|
||||
SERVICE_ALARM_DISARM,
|
||||
{ATTR_ENTITY_ID: "alarm_control_panel.test_alarm"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_concord232_client.disarm.assert_called_once_with(None)
|
||||
|
||||
|
||||
async def test_alarm_disarm_with_code(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test disarm service with code."""
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG_WITH_CODE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
ALARM_DOMAIN,
|
||||
SERVICE_ALARM_DISARM,
|
||||
{
|
||||
ATTR_ENTITY_ID: "alarm_control_panel.test_alarm",
|
||||
ATTR_CODE: "1234",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_concord232_client.disarm.assert_called_once_with("1234")
|
||||
|
||||
|
||||
async def test_alarm_disarm_invalid_code(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test disarm service with invalid code."""
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG_WITH_CODE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
ALARM_DOMAIN,
|
||||
SERVICE_ALARM_DISARM,
|
||||
{
|
||||
ATTR_ENTITY_ID: "alarm_control_panel.test_alarm",
|
||||
ATTR_CODE: "9999",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_concord232_client.disarm.assert_not_called()
|
||||
assert "Invalid code given" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "expected_arm_call"),
|
||||
[
|
||||
(SERVICE_ALARM_ARM_HOME, "stay"),
|
||||
(SERVICE_ALARM_ARM_AWAY, "away"),
|
||||
],
|
||||
)
|
||||
async def test_alarm_arm(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
service: str,
|
||||
expected_arm_call: str,
|
||||
) -> None:
|
||||
"""Test arm service."""
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG_WITH_CODE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
ALARM_DOMAIN,
|
||||
service,
|
||||
{
|
||||
ATTR_ENTITY_ID: "alarm_control_panel.test_alarm",
|
||||
ATTR_CODE: "1234",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_concord232_client.arm.assert_called_once_with(expected_arm_call)
|
||||
|
||||
|
||||
async def test_alarm_arm_home_silent_mode(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test arm home service with silent mode."""
|
||||
config_with_code = VALID_CONFIG_SILENT_MODE.copy()
|
||||
config_with_code[ALARM_DOMAIN][CONF_CODE] = "1234"
|
||||
await async_setup_component(hass, ALARM_DOMAIN, config_with_code)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
ALARM_DOMAIN,
|
||||
SERVICE_ALARM_ARM_HOME,
|
||||
{
|
||||
ATTR_ENTITY_ID: "alarm_control_panel.test_alarm",
|
||||
ATTR_CODE: "1234",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_concord232_client.arm.assert_called_once_with("stay", "silent")
|
||||
|
||||
|
||||
async def test_update_state_disarmed(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test update when alarm is disarmed."""
|
||||
mock_concord232_client.list_partitions.return_value = [{"arming_level": "Off"}]
|
||||
mock_concord232_client.list_zones.return_value = [
|
||||
{"number": 1, "name": "Zone 1", "state": "Normal"},
|
||||
]
|
||||
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("alarm_control_panel.test_alarm")
|
||||
assert state.state == AlarmControlPanelState.DISARMED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("arming_level", "expected_state"),
|
||||
[
|
||||
("Home", AlarmControlPanelState.ARMED_HOME),
|
||||
("Away", AlarmControlPanelState.ARMED_AWAY),
|
||||
],
|
||||
)
|
||||
async def test_update_state_armed(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
arming_level: str,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Test update when alarm is armed."""
|
||||
mock_concord232_client.list_partitions.return_value = [
|
||||
{"arming_level": arming_level}
|
||||
]
|
||||
mock_concord232_client.partitions = (
|
||||
mock_concord232_client.list_partitions.return_value
|
||||
)
|
||||
mock_concord232_client.list_zones.return_value = [
|
||||
{"number": 1, "name": "Zone 1", "state": "Normal"},
|
||||
]
|
||||
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Trigger update
|
||||
freezer.tick(10)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("alarm_control_panel.test_alarm")
|
||||
assert state.state == expected_state
|
||||
|
||||
|
||||
async def test_update_connection_error(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test update with connection error."""
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_concord232_client.list_partitions.side_effect = (
|
||||
requests.exceptions.ConnectionError("Connection failed")
|
||||
)
|
||||
|
||||
freezer.tick(10)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "Unable to connect to" in caplog.text
|
||||
|
||||
|
||||
async def test_update_no_partitions(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test update when no partitions are available."""
|
||||
mock_concord232_client.list_partitions.return_value = []
|
||||
|
||||
await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "Concord232 reports no partitions" in caplog.text
|
||||
201
tests/components/concord232/test_binary_sensor.py
Normal file
201
tests/components/concord232/test_binary_sensor.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Tests for the Concord232 binary sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.concord232.binary_sensor import (
|
||||
CONF_EXCLUDE_ZONES,
|
||||
CONF_ZONE_TYPES,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
VALID_CONFIG = {
|
||||
BINARY_SENSOR_DOMAIN: {
|
||||
"platform": "concord232",
|
||||
CONF_HOST: "localhost",
|
||||
CONF_PORT: 5007,
|
||||
}
|
||||
}
|
||||
|
||||
VALID_CONFIG_WITH_EXCLUDE = {
|
||||
BINARY_SENSOR_DOMAIN: {
|
||||
"platform": "concord232",
|
||||
CONF_HOST: "localhost",
|
||||
CONF_PORT: 5007,
|
||||
CONF_EXCLUDE_ZONES: [2],
|
||||
}
|
||||
}
|
||||
|
||||
VALID_CONFIG_WITH_ZONE_TYPES = {
|
||||
BINARY_SENSOR_DOMAIN: {
|
||||
"platform": "concord232",
|
||||
CONF_HOST: "localhost",
|
||||
CONF_PORT: 5007,
|
||||
CONF_ZONE_TYPES: {1: "door", 2: "window"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_setup_platform(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test platform setup."""
|
||||
await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state1 = hass.states.get("binary_sensor.zone_1")
|
||||
state2 = hass.states.get("binary_sensor.zone_2")
|
||||
assert state1 is not None
|
||||
assert state2 is not None
|
||||
assert state1.state == "off"
|
||||
assert state2.state == "off"
|
||||
|
||||
|
||||
async def test_setup_platform_connection_error(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test platform setup with connection error."""
|
||||
mock_concord232_client.list_zones.side_effect = requests.exceptions.ConnectionError(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "Unable to connect to Concord232" in caplog.text
|
||||
assert hass.states.get("binary_sensor.zone_1") is None
|
||||
|
||||
|
||||
async def test_setup_with_exclude_zones(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test platform setup with excluded zones."""
|
||||
await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG_WITH_EXCLUDE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state1 = hass.states.get("binary_sensor.zone_1")
|
||||
state2 = hass.states.get("binary_sensor.zone_2")
|
||||
assert state1 is not None
|
||||
assert state2 is None # Zone 2 should be excluded
|
||||
|
||||
|
||||
async def test_setup_with_zone_types(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test platform setup with custom zone types."""
|
||||
await async_setup_component(
|
||||
hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG_WITH_ZONE_TYPES
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state1 = hass.states.get("binary_sensor.zone_1")
|
||||
state2 = hass.states.get("binary_sensor.zone_2")
|
||||
assert state1 is not None
|
||||
assert state2 is not None
|
||||
# Check device class is set correctly
|
||||
assert state1.attributes.get("device_class") == BinarySensorDeviceClass.DOOR
|
||||
assert state2.attributes.get("device_class") == BinarySensorDeviceClass.WINDOW
|
||||
|
||||
|
||||
async def test_zone_state_faulted(
|
||||
hass: HomeAssistant, mock_concord232_client: MagicMock
|
||||
) -> None:
|
||||
"""Test zone state when faulted."""
|
||||
mock_concord232_client.list_zones.return_value = [
|
||||
{"number": 1, "name": "Zone 1", "state": "Faulted"},
|
||||
]
|
||||
mock_concord232_client.zones = mock_concord232_client.list_zones.return_value
|
||||
|
||||
await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("binary_sensor.zone_1")
|
||||
assert state.state == "on" # Faulted state means on (faulted)
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-10-21")
|
||||
async def test_zone_update_refresh(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that zone updates refresh the client data."""
|
||||
mock_concord232_client.list_zones.return_value = [
|
||||
{"number": 1, "name": "Zone 1", "state": "Normal"},
|
||||
]
|
||||
mock_concord232_client.zones = mock_concord232_client.list_zones.return_value
|
||||
|
||||
await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("binary_sensor.zone_1").state == "off"
|
||||
|
||||
# Update zone state - need to update both return_value and zones attribute
|
||||
new_zones = [
|
||||
{"number": 1, "name": "Zone 1", "state": "Faulted"},
|
||||
]
|
||||
mock_concord232_client.list_zones.return_value = new_zones
|
||||
mock_concord232_client.zones = new_zones
|
||||
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("binary_sensor.zone_1")
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sensor_name", "entity_id", "expected_device_class"),
|
||||
[
|
||||
(
|
||||
"MOTION Sensor",
|
||||
"binary_sensor.motion_sensor",
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
),
|
||||
("SMOKE Sensor", "binary_sensor.smoke_sensor", BinarySensorDeviceClass.SMOKE),
|
||||
(
|
||||
"Unknown Sensor",
|
||||
"binary_sensor.unknown_sensor",
|
||||
BinarySensorDeviceClass.OPENING,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_class(
|
||||
hass: HomeAssistant,
|
||||
mock_concord232_client: MagicMock,
|
||||
sensor_name: str,
|
||||
entity_id: str,
|
||||
expected_device_class: BinarySensorDeviceClass,
|
||||
) -> None:
|
||||
"""Test zone type detection for motion sensor."""
|
||||
mock_concord232_client.list_zones.return_value = [
|
||||
{"number": 1, "name": sensor_name, "state": "Normal"},
|
||||
]
|
||||
mock_concord232_client.zones = mock_concord232_client.list_zones.return_value
|
||||
|
||||
await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes.get("device_class") == expected_device_class
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from go2rtc_client.rest import _StreamClient, _WebRTCClient
|
||||
from go2rtc_client.rest import _SchemesClient, _StreamClient, _WebRTCClient
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
@@ -39,6 +39,23 @@ def rest_client() -> Generator[AsyncMock]:
|
||||
patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
client.schemes = schemes = Mock(spec_set=_SchemesClient)
|
||||
schemes.list.return_value = {
|
||||
"onvif",
|
||||
"exec",
|
||||
"http",
|
||||
"rtmps",
|
||||
"https",
|
||||
"rtmpx",
|
||||
"httpx",
|
||||
"rtsps",
|
||||
"webrtc",
|
||||
"rtmp",
|
||||
"tcp",
|
||||
"rtsp",
|
||||
"rtspx",
|
||||
"ffmpeg",
|
||||
}
|
||||
client.streams = streams = Mock(spec_set=_StreamClient)
|
||||
streams.list.return_value = {}
|
||||
client.validate_server_version = AsyncMock(
|
||||
|
||||
23
tests/components/go2rtc/snapshots/test_server.ambr
Normal file
23
tests/components/go2rtc/snapshots/test_server.ambr
Normal file
@@ -0,0 +1,23 @@
|
||||
# serializer version: 1
|
||||
# name: test_server_run_success[False]
|
||||
_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: "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({
|
||||
}),
|
||||
),
|
||||
])
|
||||
# ---
|
||||
# name: test_server_run_success[True]
|
||||
_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 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({
|
||||
}),
|
||||
),
|
||||
])
|
||||
# ---
|
||||
@@ -7,6 +7,7 @@ import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.go2rtc.server import Server
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -75,20 +76,17 @@ def assert_server_output_not_logged(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enable_ui", "api_ip"),
|
||||
[
|
||||
(True, ""),
|
||||
(False, "127.0.0.1"),
|
||||
],
|
||||
"enable_ui",
|
||||
[True, False],
|
||||
)
|
||||
@pytest.mark.usefixtures("rest_client")
|
||||
async def test_server_run_success(
|
||||
mock_create_subprocess: AsyncMock,
|
||||
rest_client: AsyncMock,
|
||||
server_stdout: list[str],
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_tempfile: Mock,
|
||||
api_ip: str,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test that the server runs successfully."""
|
||||
await server.start()
|
||||
@@ -104,21 +102,8 @@ async def test_server_run_success(
|
||||
)
|
||||
|
||||
# Verify that the config file was written
|
||||
mock_tempfile.write.assert_called_once_with(
|
||||
f"""# This file is managed by Home Assistant
|
||||
# Do not edit it manually
|
||||
|
||||
api:
|
||||
listen: "{api_ip}:11984"
|
||||
|
||||
rtsp:
|
||||
listen: "127.0.0.1:18554"
|
||||
|
||||
webrtc:
|
||||
listen: ":18555/tcp"
|
||||
ice_servers: []
|
||||
""".encode()
|
||||
)
|
||||
calls = mock_tempfile.write.call_args_list
|
||||
assert calls == snapshot()
|
||||
|
||||
# Verify go2rtc binary stdout was logged with debug level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.DEBUG)
|
||||
|
||||
1
tests/components/hanna/__init__.py
Normal file
1
tests/components/hanna/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Hanna integration."""
|
||||
41
tests/components/hanna/conftest.py
Normal file
41
tests/components/hanna/conftest.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Fixtures for Hanna Instruments integration tests."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hanna.const import DOMAIN
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry():
|
||||
"""Mock setting up a config entry."""
|
||||
with patch("homeassistant.components.hanna.async_setup_entry", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_hanna_client():
|
||||
"""Mock HannaCloudClient."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hanna.config_flow.HannaCloudClient", autospec=True
|
||||
) as mock_client,
|
||||
patch("homeassistant.components.hanna.HannaCloudClient", new=mock_client),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock a config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"},
|
||||
title="test@example.com",
|
||||
unique_id="test@example.com",
|
||||
)
|
||||
124
tests/components/hanna/test_config_flow.py
Normal file
124
tests/components/hanna/test_config_flow.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Tests for the Hanna Instruments integration config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from hanna_cloud import AuthenticationError
|
||||
import pytest
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout
|
||||
|
||||
from homeassistant.components.hanna.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_hanna_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test full flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@example.com"
|
||||
assert result["data"] == {
|
||||
CONF_EMAIL: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
assert result["result"].unique_id == "test@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
(
|
||||
AuthenticationError("Authentication failed"),
|
||||
"invalid_auth",
|
||||
),
|
||||
(
|
||||
Timeout("Connection timeout"),
|
||||
"cannot_connect",
|
||||
),
|
||||
(
|
||||
RequestsConnectionError("Connection failed"),
|
||||
"cannot_connect",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_error_scenarios(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_hanna_client: MagicMock,
|
||||
exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test various error scenarios in the config flow."""
|
||||
mock_hanna_client.authenticate.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
# Repatch to succeed and complete the flow
|
||||
mock_hanna_client.authenticate.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@example.com"
|
||||
assert result["data"] == {
|
||||
CONF_EMAIL: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
|
||||
|
||||
async def test_duplicate_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_hanna_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that duplicate entries are aborted."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -9,6 +9,7 @@ from syrupy.assertion import SnapshotAssertion
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.kitchen_sink import DOMAIN
|
||||
from homeassistant.components.labs import EVENT_LABS_UPDATED
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
StatisticMeanType,
|
||||
@@ -18,6 +19,7 @@ from homeassistant.components.recorder.statistics import (
|
||||
)
|
||||
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
@@ -370,3 +372,133 @@ async def test_service(
|
||||
{"field_1": 1, "field_2": "auto", "field_3": 1, "field_4": "forwards"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preview_feature_enabled", "should_create_issue"),
|
||||
[
|
||||
(False, False),
|
||||
(True, True),
|
||||
],
|
||||
ids=["preview_feature_disabled", "preview_feature_enabled"],
|
||||
)
|
||||
async def test_special_repair_preview_feature_state(
|
||||
hass: HomeAssistant,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
preview_feature_enabled: bool,
|
||||
should_create_issue: bool,
|
||||
) -> None:
|
||||
"""Test that special repair issue is created/removed based on preview feature state."""
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
if preview_feature_enabled:
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
# Enable the special repair preview feature
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# Wait for event handling
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check if issue exists based on preview feature state
|
||||
issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue")
|
||||
if should_create_issue:
|
||||
assert issue is not None
|
||||
assert issue.domain == DOMAIN
|
||||
assert issue.translation_key == "special_repair"
|
||||
assert issue.is_fixable is False
|
||||
assert issue.severity == ir.IssueSeverity.WARNING
|
||||
else:
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_special_repair_preview_feature_toggle(
|
||||
hass: HomeAssistant,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test that special repair issue is created/deleted when preview feature is toggled."""
|
||||
# Setup repairs and kitchen_sink first
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
# Enable the special repair preview feature
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check issue exists
|
||||
issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue")
|
||||
assert issue is not None
|
||||
|
||||
# Disable the special repair preview feature
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check issue is removed
|
||||
issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue")
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_preview_feature_event_handler_registered(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that preview feature event handler is registered on setup."""
|
||||
# Setup kitchen_sink
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Track if event is handled
|
||||
events_received = []
|
||||
|
||||
def track_event(event):
|
||||
events_received.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, track_event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Fire a labs updated event for kitchen_sink preview feature
|
||||
hass.bus.async_fire(
|
||||
EVENT_LABS_UPDATED,
|
||||
{"feature_id": "kitchen_sink.special_repair", "enabled": True},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify event was received by our tracker
|
||||
assert len(events_received) == 1
|
||||
assert events_received[0].data["feature_id"] == "kitchen_sink.special_repair"
|
||||
|
||||
1
tests/components/labs/__init__.py
Normal file
1
tests/components/labs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Home Assistant Labs integration."""
|
||||
423
tests/components/labs/test_init.py
Normal file
423
tests/components/labs/test_init.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""Tests for the Home Assistant Labs integration setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EVENT_LABS_UPDATED,
|
||||
LabsStorage,
|
||||
async_is_preview_feature_enabled,
|
||||
async_setup,
|
||||
)
|
||||
from homeassistant.components.labs.const import LABS_DATA, LabPreviewFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.loader import Integration
|
||||
|
||||
|
||||
async def test_async_setup(hass: HomeAssistant) -> None:
|
||||
"""Test the Labs integration setup."""
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify WebSocket commands are registered
|
||||
assert "labs/list" in hass.data["websocket_api"]
|
||||
assert "labs/update" in hass.data["websocket_api"]
|
||||
|
||||
|
||||
async def test_async_is_preview_feature_enabled_not_setup(hass: HomeAssistant) -> None:
|
||||
"""Test checking if preview feature is enabled before setup returns False."""
|
||||
# Don't set up labs integration
|
||||
result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_async_is_preview_feature_enabled_nonexistent(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test checking if non-existent preview feature is enabled."""
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = async_is_preview_feature_enabled(
|
||||
hass, "kitchen_sink", "nonexistent_feature"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_async_is_preview_feature_enabled_when_enabled(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test checking if preview feature is enabled."""
|
||||
# Load kitchen_sink integration so preview feature exists
|
||||
hass.config.components.add("kitchen_sink")
|
||||
|
||||
# Enable a preview feature via storage
|
||||
hass_storage["core.labs"] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.labs",
|
||||
"data": {
|
||||
"preview_feature_status": [
|
||||
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_async_is_preview_feature_enabled_when_disabled(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test checking if preview feature is disabled (not in storage)."""
|
||||
# Load kitchen_sink integration so preview feature exists
|
||||
hass.config.components.add("kitchen_sink")
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("features_to_store", "expected_enabled", "expected_cleaned"),
|
||||
[
|
||||
# Single stale feature cleanup
|
||||
(
|
||||
[
|
||||
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
|
||||
{"domain": "nonexistent_domain", "preview_feature": "fake_feature"},
|
||||
],
|
||||
[("kitchen_sink", "special_repair")],
|
||||
[("nonexistent_domain", "fake_feature")],
|
||||
),
|
||||
# Multiple stale features cleanup
|
||||
(
|
||||
[
|
||||
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
|
||||
{"domain": "stale_domain_1", "preview_feature": "old_feature"},
|
||||
{"domain": "stale_domain_2", "preview_feature": "another_old"},
|
||||
{"domain": "stale_domain_3", "preview_feature": "yet_another"},
|
||||
],
|
||||
[("kitchen_sink", "special_repair")],
|
||||
[
|
||||
("stale_domain_1", "old_feature"),
|
||||
("stale_domain_2", "another_old"),
|
||||
("stale_domain_3", "yet_another"),
|
||||
],
|
||||
),
|
||||
# All features cleaned (no integrations loaded)
|
||||
(
|
||||
[{"domain": "nonexistent", "preview_feature": "fake"}],
|
||||
[],
|
||||
[("nonexistent", "fake")],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_storage_cleanup_stale_features(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
features_to_store: list[dict[str, str]],
|
||||
expected_enabled: list[tuple[str, str]],
|
||||
expected_cleaned: list[tuple[str, str]],
|
||||
) -> None:
|
||||
"""Test that stale preview features are removed from storage on setup."""
|
||||
# Load kitchen_sink only if we expect any features to remain
|
||||
if expected_enabled:
|
||||
hass.config.components.add("kitchen_sink")
|
||||
|
||||
hass_storage["core.labs"] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.labs",
|
||||
"data": {"preview_feature_status": features_to_store},
|
||||
}
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify expected features are preserved
|
||||
for domain, feature in expected_enabled:
|
||||
assert async_is_preview_feature_enabled(hass, domain, feature)
|
||||
|
||||
# Verify stale features were cleaned up
|
||||
for domain, feature in expected_cleaned:
|
||||
assert not async_is_preview_feature_enabled(hass, domain, feature)
|
||||
|
||||
|
||||
async def test_event_fired_on_preview_feature_update(hass: HomeAssistant) -> None:
|
||||
"""Test that labs_updated event is fired when preview feature is toggled."""
|
||||
# Load kitchen_sink integration
|
||||
hass.config.components.add("kitchen_sink")
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
events = []
|
||||
|
||||
def event_listener(event):
|
||||
events.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
|
||||
|
||||
# Fire event manually to test listener (websocket handler does this)
|
||||
hass.bus.async_fire(
|
||||
EVENT_LABS_UPDATED,
|
||||
{
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].data["domain"] == "kitchen_sink"
|
||||
assert events[0].data["preview_feature"] == "special_repair"
|
||||
assert events[0].data["enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("domain", "preview_feature", "expected"),
|
||||
[
|
||||
("kitchen_sink", "special_repair", True),
|
||||
("other", "nonexistent", False),
|
||||
("kitchen_sink", "nonexistent", False),
|
||||
],
|
||||
)
|
||||
async def test_async_is_preview_feature_enabled(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
domain: str,
|
||||
preview_feature: str,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""Test async_is_preview_feature_enabled."""
|
||||
# Enable the kitchen_sink.special_repair preview feature via storage
|
||||
hass_storage["core.labs"] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.labs",
|
||||
"data": {
|
||||
"preview_feature_status": [
|
||||
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = async_is_preview_feature_enabled(hass, domain, preview_feature)
|
||||
assert result is expected
|
||||
|
||||
|
||||
async def test_multiple_setups_idempotent(hass: HomeAssistant) -> None:
|
||||
"""Test that calling async_setup multiple times is safe."""
|
||||
result1 = await async_setup(hass, {})
|
||||
assert result1 is True
|
||||
|
||||
result2 = await async_setup(hass, {})
|
||||
assert result2 is True
|
||||
|
||||
# Verify store is still accessible
|
||||
assert LABS_DATA in hass.data
|
||||
|
||||
|
||||
async def test_storage_load_missing_preview_feature_status_key(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test loading storage when preview_feature_status key is missing."""
|
||||
# Storage data without preview_feature_status key
|
||||
hass_storage["core.labs"] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.labs",
|
||||
"data": {}, # Missing preview_feature_status
|
||||
}
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Should initialize correctly - verify no feature is enabled
|
||||
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
|
||||
|
||||
async def test_preview_feature_full_key(hass: HomeAssistant) -> None:
|
||||
"""Test that preview feature full_key property returns correct format."""
|
||||
feature = LabPreviewFeature(
|
||||
domain="test_domain",
|
||||
preview_feature="test_feature",
|
||||
feedback_url="https://feedback.example.com",
|
||||
)
|
||||
|
||||
assert feature.full_key == "test_domain.test_feature"
|
||||
|
||||
|
||||
async def test_preview_feature_to_dict_with_all_urls(hass: HomeAssistant) -> None:
|
||||
"""Test LabPreviewFeature.to_dict with all URLs populated."""
|
||||
feature = LabPreviewFeature(
|
||||
domain="test_domain",
|
||||
preview_feature="test_feature",
|
||||
feedback_url="https://feedback.example.com",
|
||||
learn_more_url="https://learn.example.com",
|
||||
report_issue_url="https://issue.example.com",
|
||||
)
|
||||
|
||||
result = feature.to_dict(enabled=True)
|
||||
|
||||
assert result == {
|
||||
"preview_feature": "test_feature",
|
||||
"domain": "test_domain",
|
||||
"enabled": True,
|
||||
"is_built_in": True,
|
||||
"feedback_url": "https://feedback.example.com",
|
||||
"learn_more_url": "https://learn.example.com",
|
||||
"report_issue_url": "https://issue.example.com",
|
||||
}
|
||||
|
||||
|
||||
async def test_preview_feature_to_dict_with_no_urls(hass: HomeAssistant) -> None:
|
||||
"""Test LabPreviewFeature.to_dict with no URLs (all None)."""
|
||||
feature = LabPreviewFeature(
|
||||
domain="test_domain",
|
||||
preview_feature="test_feature",
|
||||
)
|
||||
|
||||
result = feature.to_dict(enabled=False)
|
||||
|
||||
assert result == {
|
||||
"preview_feature": "test_feature",
|
||||
"domain": "test_domain",
|
||||
"enabled": False,
|
||||
"is_built_in": True,
|
||||
"feedback_url": None,
|
||||
"learn_more_url": None,
|
||||
"report_issue_url": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_storage_load_returns_none_when_no_file(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test storage load when no file exists (returns None)."""
|
||||
# Create a storage instance but don't write any data
|
||||
store = LabsStorage(hass, 1, "test_labs_none.json")
|
||||
|
||||
# Mock the parent Store's _async_load_data to return None
|
||||
# This simulates the edge case where Store._async_load_data returns None
|
||||
# This tests line 60: return None
|
||||
async def mock_load_none():
|
||||
return None
|
||||
|
||||
with patch.object(Store, "_async_load_data", new=mock_load_none):
|
||||
result = await store.async_load()
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_custom_integration_with_preview_features(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that custom integrations with preview features are loaded."""
|
||||
# Create a mock custom integration with preview features
|
||||
mock_integration = Mock(spec=Integration)
|
||||
mock_integration.domain = "custom_test"
|
||||
mock_integration.preview_features = {
|
||||
"test_feature": {
|
||||
"feedback_url": "https://feedback.test",
|
||||
"learn_more_url": "https://learn.test",
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_get_custom_components",
|
||||
return_value={"custom_test": mock_integration},
|
||||
):
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify the custom integration's preview feature can be checked
|
||||
# (This confirms it was loaded properly)
|
||||
assert not async_is_preview_feature_enabled(hass, "custom_test", "test_feature")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("is_custom", "expected_is_built_in"),
|
||||
[
|
||||
(False, True), # Built-in integration
|
||||
(True, False), # Custom integration
|
||||
],
|
||||
)
|
||||
async def test_preview_feature_is_built_in_flag(
|
||||
hass: HomeAssistant,
|
||||
is_custom: bool,
|
||||
expected_is_built_in: bool,
|
||||
) -> None:
|
||||
"""Test that preview features have correct is_built_in flag."""
|
||||
if is_custom:
|
||||
# Create a mock custom integration
|
||||
mock_integration = Mock(spec=Integration)
|
||||
mock_integration.domain = "custom_test"
|
||||
mock_integration.preview_features = {
|
||||
"custom_feature": {"feedback_url": "https://feedback.test"}
|
||||
}
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_get_custom_components",
|
||||
return_value={"custom_test": mock_integration},
|
||||
):
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
feature_key = "custom_test.custom_feature"
|
||||
else:
|
||||
# Load built-in kitchen_sink integration
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
feature_key = "kitchen_sink.special_repair"
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
assert feature_key in labs_data.preview_features
|
||||
feature = labs_data.preview_features[feature_key]
|
||||
assert feature.is_built_in is expected_is_built_in
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("is_built_in", "expected_default"),
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
(None, True), # Default value when not specified
|
||||
],
|
||||
)
|
||||
async def test_preview_feature_to_dict_is_built_in(
|
||||
hass: HomeAssistant,
|
||||
is_built_in: bool | None,
|
||||
expected_default: bool,
|
||||
) -> None:
|
||||
"""Test that to_dict correctly handles is_built_in field."""
|
||||
if is_built_in is None:
|
||||
# Test default value
|
||||
feature = LabPreviewFeature(
|
||||
domain="test_domain",
|
||||
preview_feature="test_feature",
|
||||
)
|
||||
else:
|
||||
feature = LabPreviewFeature(
|
||||
domain="test_domain",
|
||||
preview_feature="test_feature",
|
||||
is_built_in=is_built_in,
|
||||
)
|
||||
|
||||
assert feature.is_built_in is expected_default
|
||||
result = feature.to_dict(enabled=True)
|
||||
assert result["is_built_in"] is expected_default
|
||||
654
tests/components/labs/test_websocket_api.py
Normal file
654
tests/components/labs/test_websocket_api.py
Normal file
@@ -0,0 +1,654 @@
|
||||
"""Tests for the Home Assistant Labs WebSocket API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EVENT_LABS_UPDATED,
|
||||
async_is_preview_feature_enabled,
|
||||
async_setup,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockUser
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("load_integration", "expected_features"),
|
||||
[
|
||||
(False, []), # No integration loaded
|
||||
(
|
||||
True, # Integration loaded
|
||||
[
|
||||
{
|
||||
"preview_feature": "special_repair",
|
||||
"domain": "kitchen_sink",
|
||||
"enabled": False,
|
||||
"is_built_in": True,
|
||||
"feedback_url": ANY,
|
||||
"learn_more_url": ANY,
|
||||
"report_issue_url": ANY,
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_websocket_list_preview_features(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
load_integration: bool,
|
||||
expected_features: list,
|
||||
) -> None:
|
||||
"""Test listing preview features with different integration states."""
|
||||
if load_integration:
|
||||
hass.config.components.add("kitchen_sink")
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {"features": expected_features}
|
||||
|
||||
|
||||
async def test_websocket_update_preview_feature_enable(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test enabling a preview feature via WebSocket."""
|
||||
# Load kitchen_sink integration
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Track events
|
||||
events = []
|
||||
|
||||
def event_listener(event):
|
||||
events.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
|
||||
|
||||
# Enable the preview feature
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] is None
|
||||
|
||||
# Verify event was fired
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == 1
|
||||
assert events[0].data["domain"] == "kitchen_sink"
|
||||
assert events[0].data["preview_feature"] == "special_repair"
|
||||
assert events[0].data["enabled"] is True
|
||||
|
||||
# Verify feature is now enabled
|
||||
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
|
||||
|
||||
async def test_websocket_update_preview_feature_disable(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test disabling a preview feature via WebSocket."""
|
||||
# Pre-populate storage with enabled preview feature
|
||||
hass_storage["core.labs"] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.labs",
|
||||
"data": {
|
||||
"preview_feature_status": [
|
||||
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 5,
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# Verify feature is disabled
|
||||
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
|
||||
|
||||
async def test_websocket_update_nonexistent_feature(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test updating a preview feature that doesn't exist."""
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "nonexistent",
|
||||
"preview_feature": "feature",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "not_found"
|
||||
assert "not found" in msg["error"]["message"].lower()
|
||||
|
||||
|
||||
async def test_websocket_update_unavailable_preview_feature(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test updating a preview feature whose integration is not loaded still works."""
|
||||
# Don't load kitchen_sink integration
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Preview feature is pre-loaded, so update succeeds even though integration isn't loaded
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_type",
|
||||
["labs/list", "labs/update"],
|
||||
)
|
||||
async def test_websocket_requires_admin(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_admin_user: MockUser,
|
||||
command_type: str,
|
||||
) -> None:
|
||||
"""Test that websocket commands require admin privileges."""
|
||||
# Remove admin privileges
|
||||
hass_admin_user.groups = []
|
||||
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
command = {"type": command_type}
|
||||
if command_type == "labs/update":
|
||||
command.update(
|
||||
{
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(command)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "unauthorized"
|
||||
|
||||
|
||||
async def test_websocket_update_validates_enabled_parameter(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that enabled parameter must be boolean."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Try with string instead of boolean
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": "true",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
# Validation error from voluptuous
|
||||
|
||||
|
||||
async def test_storage_persists_preview_feature_across_calls(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that storage persists preview feature state across multiple calls."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Enable the preview feature
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# List preview features - should show enabled
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["features"][0]["enabled"] is True
|
||||
|
||||
# Disable preview feature
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# List preview features - should show disabled
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["features"][0]["enabled"] is False
|
||||
|
||||
|
||||
async def test_preview_feature_urls_present(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that preview features include feedback and report URLs."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
feature = msg["result"]["features"][0]
|
||||
assert "feedback_url" in feature
|
||||
assert "learn_more_url" in feature
|
||||
assert "report_issue_url" in feature
|
||||
assert feature["feedback_url"] is not None
|
||||
assert feature["learn_more_url"] is not None
|
||||
assert feature["report_issue_url"] is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"create_backup",
|
||||
"backup_fails",
|
||||
"enabled",
|
||||
"should_call_backup",
|
||||
"should_succeed",
|
||||
),
|
||||
[
|
||||
# Enable with successful backup
|
||||
(True, False, True, True, True),
|
||||
# Enable with failed backup
|
||||
(True, True, True, True, False),
|
||||
# Disable ignores backup flag
|
||||
(True, False, False, False, True),
|
||||
],
|
||||
ids=[
|
||||
"enable_with_backup_success",
|
||||
"enable_with_backup_failure",
|
||||
"disable_ignores_backup",
|
||||
],
|
||||
)
|
||||
async def test_websocket_update_preview_feature_backup_scenarios(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
create_backup: bool,
|
||||
backup_fails: bool,
|
||||
enabled: bool,
|
||||
should_call_backup: bool,
|
||||
should_succeed: bool,
|
||||
) -> None:
|
||||
"""Test various backup scenarios when updating preview features."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Mock the backup manager
|
||||
mock_backup_manager = AsyncMock()
|
||||
if backup_fails:
|
||||
mock_backup_manager.async_create_automatic_backup = AsyncMock(
|
||||
side_effect=Exception("Backup failed")
|
||||
)
|
||||
else:
|
||||
mock_backup_manager.async_create_automatic_backup = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_get_manager",
|
||||
return_value=mock_backup_manager,
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": enabled,
|
||||
"create_backup": create_backup,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
if should_succeed:
|
||||
assert msg["success"]
|
||||
if should_call_backup:
|
||||
mock_backup_manager.async_create_automatic_backup.assert_called_once()
|
||||
else:
|
||||
mock_backup_manager.async_create_automatic_backup.assert_not_called()
|
||||
else:
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "unknown_error"
|
||||
assert "backup" in msg["error"]["message"].lower()
|
||||
# Verify preview feature was NOT enabled
|
||||
assert not async_is_preview_feature_enabled(
|
||||
hass, "kitchen_sink", "special_repair"
|
||||
)
|
||||
|
||||
|
||||
async def test_websocket_list_multiple_enabled_features(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test listing when multiple preview features are enabled."""
|
||||
# Pre-populate with multiple enabled features
|
||||
hass_storage["core.labs"] = {
|
||||
"version": 1,
|
||||
"data": {
|
||||
"preview_feature_status": [
|
||||
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
features = msg["result"]["features"]
|
||||
assert len(features) >= 1
|
||||
# Verify at least one is enabled
|
||||
enabled_features = [f for f in features if f["enabled"]]
|
||||
assert len(enabled_features) == 1
|
||||
|
||||
|
||||
async def test_websocket_update_rapid_toggle(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test rapid toggling of a preview feature."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Enable
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg1 = await client.receive_json()
|
||||
assert msg1["success"]
|
||||
|
||||
# Disable immediately
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
msg2 = await client.receive_json()
|
||||
assert msg2["success"]
|
||||
|
||||
# Enable again
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg3 = await client.receive_json()
|
||||
assert msg3["success"]
|
||||
|
||||
# Final state should be enabled
|
||||
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
|
||||
|
||||
async def test_websocket_update_same_state_idempotent(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that enabling an already-enabled feature is idempotent."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Enable feature
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg1 = await client.receive_json()
|
||||
assert msg1["success"]
|
||||
|
||||
# Enable again (should be idempotent)
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg2 = await client.receive_json()
|
||||
assert msg2["success"]
|
||||
|
||||
# Should still be enabled
|
||||
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
||||
|
||||
|
||||
async def test_websocket_list_filtered_by_loaded_components(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that list only shows features from loaded integrations."""
|
||||
# Don't load kitchen_sink - its preview feature shouldn't appear
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
# Should be empty since kitchen_sink isn't loaded
|
||||
assert msg["result"]["features"] == []
|
||||
|
||||
# Now load kitchen_sink
|
||||
hass.config.components.add("kitchen_sink")
|
||||
|
||||
await client.send_json_auto_id({"type": "labs/list"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
# Now should have kitchen_sink features
|
||||
assert len(msg["result"]["features"]) >= 1
|
||||
|
||||
|
||||
async def test_websocket_update_with_missing_required_field(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that missing required fields are rejected."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Missing 'enabled' field
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
# enabled is missing
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
# Should get validation error
|
||||
|
||||
|
||||
async def test_websocket_event_data_structure(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test that event data has correct structure."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
events = []
|
||||
|
||||
def event_listener(event):
|
||||
events.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
|
||||
|
||||
# Enable a feature
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
await client.receive_json()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(events) == 1
|
||||
event_data = events[0].data
|
||||
# Verify all required fields are present
|
||||
assert "domain" in event_data
|
||||
assert "preview_feature" in event_data
|
||||
assert "enabled" in event_data
|
||||
assert event_data["domain"] == "kitchen_sink"
|
||||
assert event_data["preview_feature"] == "special_repair"
|
||||
assert event_data["enabled"] is True
|
||||
assert isinstance(event_data["enabled"], bool)
|
||||
|
||||
|
||||
async def test_websocket_backup_timeout_handling(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test handling of backup timeout/long-running backup."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Mock backup manager with timeout
|
||||
mock_backup_manager = AsyncMock()
|
||||
mock_backup_manager.async_create_automatic_backup = AsyncMock(
|
||||
side_effect=TimeoutError("Backup timed out")
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_get_manager",
|
||||
return_value=mock_backup_manager,
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
"create_backup": True,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "unknown_error"
|
||||
@@ -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",
|
||||
|
||||
526
tests/components/matter/fixtures/nodes/mock_thermostat.json
Normal file
526
tests/components/matter/fixtures/nodes/mock_thermostat.json
Normal 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": []
|
||||
}
|
||||
@@ -107,7 +107,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.aqara_door_and_window_sensor_p2_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -156,7 +156,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.aqara_motion_and_light_sensor_p2_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -205,7 +205,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.aqara_motion_and_light_sensor_p2_identify_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -254,7 +254,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.presence_multi_sensor_fp300_1_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -303,7 +303,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.presence_multi_sensor_fp300_1_identify_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -352,7 +352,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.presence_multi_sensor_fp300_1_identify_3',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -401,7 +401,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.presence_multi_sensor_fp300_1_identify_4',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -450,7 +450,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.climate_sensor_w100_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -499,7 +499,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.climate_sensor_w100_identify_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -548,7 +548,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.climate_sensor_w100_identify_3',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -597,7 +597,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.climate_sensor_w100_identify_4',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -646,7 +646,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.climate_sensor_w100_identify_5',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -695,7 +695,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.floor_heating_thermostat_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -744,7 +744,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.floor_heating_thermostat_identify_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -793,7 +793,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.aqara_smart_lock_u200_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -842,7 +842,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_color_temperature_light_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -891,7 +891,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.dimmable_plugin_unit_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -940,7 +940,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.connected_thermostat_ute_3000_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -989,7 +989,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.ecodeebot_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1038,7 +1038,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_door_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1087,7 +1087,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_energy_20ecn4101_identify_bottom',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1136,7 +1136,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_energy_20ecn4101_identify_top',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1185,7 +1185,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_energy_plug_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1234,7 +1234,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_energy_plug_patched_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1283,7 +1283,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_shutter_switch_20eci1701_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1332,7 +1332,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_thermo_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1381,7 +1381,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_weather_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1430,7 +1430,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_weather_identify_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1479,7 +1479,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_extended_color_light_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1624,7 +1624,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mocked_fan_switch_identify_fan',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1673,7 +1673,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.hjmt_6b_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1722,7 +1722,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.smart_motion_sensor_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -1771,7 +1771,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.white_series_onoff_switch_identify_load_control',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2012,7 +2012,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.longan_link_hvac_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2253,7 +2253,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_lock_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -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({
|
||||
@@ -2302,7 +2400,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.inovelli_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2351,7 +2449,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.inovelli_identify_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2400,7 +2498,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.inovelli_identify_6',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2449,7 +2547,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.inovelli_identify_config',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2498,7 +2596,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.inovelli_identify_down',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2547,7 +2645,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.inovelli_identify_up',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2596,7 +2694,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_occupancy_sensor_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2645,7 +2743,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_onoffpluginunit_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2694,7 +2792,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.d215s_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2743,7 +2841,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.dishwasher_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2936,7 +3034,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.laundrywasher_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3177,7 +3275,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.light_switch_example_identify_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3226,7 +3324,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.refrigerator_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3323,7 +3421,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.smoke_sensor_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3420,7 +3518,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_switchunit_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3469,7 +3567,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.smart_radiator_thermostat_x_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3518,7 +3616,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.mock_temperature_sensor_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3567,7 +3665,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.longan_link_wncv_da01_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3616,7 +3714,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.yndx_00540_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user