Compare commits

...

53 Commits

Author SHA1 Message Date
Ludovic BOUÉ
725bd3d671 Add mock thermostat entity and state snapshots for temperature display mode 2025-11-21 12:38:04 +00:00
Ludovic BOUÉ
cfc4fa6342 Merge branch 'dev' into setpoint_change_source 2025-11-21 13:35:31 +01:00
J. Nick Koston
32a40e5919 Bump PySwichBot to 0.74.0 (#156986) 2025-11-21 13:22:55 +01:00
Kamil Breguła
97de944a14 Add Washer Water Temperature to SmartThings (#156980)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-21 13:19:08 +01:00
Artur Pragacz
c9bd87f4b3 Classify identify button as diagnostic in Matter (#156943) 2025-11-21 13:17:26 +01:00
Neal
ac46568996 Add tests to concord232 component (#156070)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 13:13:08 +01:00
J. Nick Koston
7c1b8ee02c Bump aioshelly to 13.20.0 (#156988) 2025-11-21 06:11:03 -06:00
dependabot[bot]
aa6901265d Bump actions/checkout from 5.0.1 to 6.0.0 (#156973)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-21 12:56:33 +01:00
epenet
b76e9ad1c0 Migrate Tuya light (color_data) to use wrapper class (#156816) 2025-11-21 13:05:12 +02:00
epenet
edb8007c65 Migrate Tuya climate (temperature) to use wrapper class (#156977) 2025-11-21 13:03:58 +02:00
epenet
956a29411f Migrate Tuya fan (oscillate) to use wrapper class (#156946) 2025-11-21 11:56:24 +01:00
Brett Adams
1a2361050b Add update platform to Tesla Fleet (#156908) 2025-11-21 11:54:20 +01:00
Jan Bouwhuis
0c9e92f6f9 Add MQTT text subentry support (#156686) 2025-11-21 11:46:19 +01:00
epenet
bfdff46859 Migrate Tuya fan (speed) to use wrapper class (#156976) 2025-11-21 11:45:42 +01:00
epenet
9a22808499 Migrate Tuya fan (direction) to use wrapper class (#156944) 2025-11-21 11:44:16 +01:00
epenet
88b373af41 Migrate Tuya climate (swing) to use wrapper class (#156938) 2025-11-21 11:41:01 +01:00
epenet
dea2f37e8f Migrate Tuya cover (state) to use wrapper class (#156941) 2025-11-21 11:38:05 +01:00
David Rapan
30cce68e0b Update Shelly's quality scale to platinum 🏆️ (#156982) 2025-11-21 12:36:58 +02:00
Shay Levy
985eff972a Mark Shelly entity translations as done (#155683) 2025-11-21 12:10:46 +02:00
David Rapan
31ca332158 Increase Shelly code coverage for Gen1 EM3 (#156752)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-21 11:02:53 +01:00
David Rapan
bf76c1601d Align Shelly event naming paradigm (#156774)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-21 09:15:26 +01:00
J. Nick Koston
e572f8d48f Fix Shelly Bluetooth discovery for Gen3/Gen4 devices without advertised names (#156883) 2025-11-20 15:11:17 -06:00
Franck Nijhof
482b5d49a3 Introduce Home Assistant Labs (#156840) 2025-11-20 21:22:37 +01:00
Robert Resch
126fd217e7 Bump go2rtc to 1.9.12 and go2rtc-client to 0.3.0 (#156948) 2025-11-20 19:41:40 +01:00
Manu
0327b0e1ec Fix next alarm sensor showing wrong time in Sleep as Android (#156939) 2025-11-20 18:56:58 +01:00
epenet
3d5a7b4813 Migrate Tuya vacuum (pause) to use wrapper class (#156947) 2025-11-20 18:55:03 +01:00
epenet
e0bb30f63b Migrate Tuya fan (switch) to use wrapper class (#156936) 2025-11-20 15:04:36 +01:00
epenet
e5ae58c5df Migrate Tuya cover (open/close/stop) to use wrapper class (#156726) 2025-11-20 15:24:58 +02:00
epenet
13e4bb4b93 Migrate Tuya climate (hvac_mode/presets) to use wrapper class (#156933) 2025-11-20 14:23:28 +01:00
epenet
d5fd27d2a2 Add tests for Tuya climate actions (#156935) 2025-11-20 15:23:04 +02:00
bestycame
0a034b9984 Add Hanna integration (#147085)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Olivier d'Otreppe <odotreppe@abbove.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-20 14:20:14 +01:00
epenet
6a8106c0eb Add tests for Tuya fan actions (#156919) 2025-11-20 14:25:05 +02:00
epenet
2cacfc7413 Migrate Tuya fan (preset) to use wrapper class (#156922) 2025-11-20 13:19:37 +01:00
epenet
388ab5c16c Migrate Tuya climate (fan_mode) to use wrapper class (#156721) 2025-11-20 13:02:33 +01:00
epenet
81ea6f8c25 Migrate Tuya vacuum (status) to use wrapper class (#156744) 2025-11-20 14:01:18 +02:00
Joost Lekkerkerker
4f885994b7 Remove deprecation for SmartThings binary sensor (#156924) 2025-11-20 11:53:50 +01:00
Hessel
25e2c9ee80 Cache token info in Wallbox (#154147)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-20 11:12:11 +01:00
epenet
12c04f5571 Use pytest.parametrize in Tuya siren/switch/valve tests (#156920) 2025-11-20 10:46:57 +01:00
epenet
3ad1c6a47a Use pytest.parametrize in Tuya cover tests (#156921) 2025-11-20 11:38:14 +02:00
Åke Strandberg
e7e13ecc74 Refactor miele program id codes part 3(3) (#144196)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-20 09:59:28 +01:00
J. Nick Koston
991b8d2040 Bump aioshelly to 13.19.0 (#156902) 2025-11-19 17:52:55 -06:00
Ludovic BOUÉ
b650e71660 Update mock thermostat snapshots with new attributes and state values 2025-11-18 18:05:29 +00:00
Ludovic BOUÉ
9ddf15e348 Update mock thermostat JSON with additional attributes and values 2025-11-18 18:03:46 +00:00
Ludovic BOUÉ
15082f9111 Merge branch 'dev' into setpoint_change_source 2025-11-18 16:45:05 +01:00
Ludovic BOUÉ
12f16611ff Rename mock thermostat entity IDs and friendly names in snapshots for consistency 2025-11-18 15:30:39 +00:00
Ludovic BOUÉ
8041be3d08 Merge branch 'dev' into setpoint_change_source 2025-11-18 14:08:38 +01:00
Ludovic BOUÉ
40b021e755 Add tests for Thermostat SetpointChangeSource, Timestamp, and Amount sensors 2025-11-18 13:02:44 +00:00
Ludovic BOUÉ
aab57eda96 Update mock thermostat product name to "Mock Thermostat" 2025-11-18 13:00:26 +00:00
Ludovic BOUÉ
f0dd37caa5 Add mock thermostat sensors and states for testing 2025-11-18 12:49:32 +00:00
Ludovic BOUÉ
662b178495 Remove unused attribute from thermostat fixture 2025-11-18 12:48:56 +00:00
Ludovic BOUÉ
cb3d30884a Add mock thermostat fixture for integration tests 2025-11-18 12:48:21 +00:00
Ludovic BOUÉ
49e6f20372 Add Setpoint Change Source timestamp and amount sensors with localization strings 2025-11-18 12:39:28 +00:00
Ludovic BOUÉ
75d02661eb Add Setpoint Change Source sensor and localization strings 2025-11-14 17:19:28 +00:00
127 changed files with 7412 additions and 2315 deletions

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"

View File

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

View File

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

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

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

View File

@@ -0,0 +1,3 @@
"""Constants for the Hanna integration."""
DOMAIN = "hanna"

View 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

View 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"),
)

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

View 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

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

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

View File

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

View File

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

View File

@@ -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",

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

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

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

View File

@@ -0,0 +1,3 @@
{
"title": "Home Assistant Labs"
}

View File

@@ -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),
),

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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):

View File

@@ -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",

View File

@@ -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),

View File

@@ -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"

View File

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

View File

@@ -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(
{

View File

@@ -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},
}
)

View File

@@ -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."""

View File

@@ -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*",

View File

@@ -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

View File

@@ -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),

View File

@@ -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",

View File

@@ -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):

View File

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

View File

@@ -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,
),
}

View File

@@ -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"

View File

@@ -41,5 +41,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.73.0"]
"requirements": ["PySwitchbot==0.74.0"]
}

View File

@@ -50,6 +50,7 @@ PLATFORMS: Final = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData]

View File

@@ -576,6 +576,11 @@
"vehicle_state_valet_mode": {
"name": "Valet mode"
}
},
"update": {
"vehicle_state_software_update_status": {
"name": "[%key:component::update::title%]"
}
}
},
"exceptions": {

View 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

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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)

View File

@@ -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"]),
)

View File

@@ -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."""

View File

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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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*",

View File

@@ -264,6 +264,7 @@ FLOWS = {
"growatt_server",
"guardian",
"habitica",
"hanna",
"harmony",
"heos",
"here_travel_time",

View File

@@ -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
View 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",
},
},
}

View File

@@ -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."""

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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
View 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"])

View File

@@ -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(),
}
)
}
),
}
)

View File

@@ -2189,6 +2189,7 @@ NO_QUALITY_SCALE = [
"input_text",
"intent_script",
"intent",
"labs",
"logbook",
"logger",
"lovelace",

View File

@@ -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(

View File

@@ -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"

View File

@@ -0,0 +1 @@
"""Tests for the Concord232 integration."""

View 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

View 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

View 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

View File

@@ -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(

View 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({
}),
),
])
# ---

View File

@@ -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)

View File

@@ -0,0 +1 @@
"""Tests for the Hanna integration."""

View 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",
)

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

View File

@@ -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"

View File

@@ -0,0 +1 @@
"""Tests for the Home Assistant Labs integration."""

View 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

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

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