mirror of
https://github.com/home-assistant/core.git
synced 2026-05-17 04:11:45 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30f8f0517f | |||
| 3f31be37f5 | |||
| cf0a14f92b | |||
| 2fcbd50784 | |||
| c08743f907 | |||
| a67ea6d4f7 | |||
| d17f6a1509 | |||
| f3932f2342 | |||
| 598be31daf | |||
| 9b2a81614f | |||
| f53c89d3bc | |||
| ac6991072f | |||
| 018e8e06fa | |||
| 0ffc9694a7 | |||
| 8d8b30a41e | |||
| 9b7f61d862 | |||
| 368f2f44be | |||
| ad6a910244 | |||
| 840b44039d | |||
| 1943675a64 |
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -35,6 +36,27 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_host: str
|
||||
_box_name: str
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
|
||||
|
||||
try:
|
||||
box_name, _ = await self._validate_input(discovery_info.ip)
|
||||
except DucoConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except DucoError:
|
||||
_LOGGER.exception("Unexpected error discovering Duco box via DHCP")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
self._host = discovery_info.ip
|
||||
self._box_name = box_name
|
||||
self.context["title_placeholders"] = {"name": box_name}
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
"name": "Duco",
|
||||
"codeowners": ["@ronaldvdmeer"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/duco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.3.2"],
|
||||
"requirements": ["python-duco-client==0.3.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"common": {
|
||||
"api_key": "Access token",
|
||||
"api_key_description": "The access token for authenticating with Firefly III",
|
||||
"verify_ssl_description": "Verify the SSL certificate of the Firefly III instance"
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
@@ -14,39 +19,39 @@
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The new API access token for authenticating with Firefly III"
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key_description%]"
|
||||
},
|
||||
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Remote access and tokens**. Create a new personal access token and copy it (it will only display once)."
|
||||
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Remote access and tokens**. Create a new **personal access token** and copy it (it will only display once)."
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::firefly_iii::config::step::user::data_description::api_key%]",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key_description%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:component::firefly_iii::config::step::user::data_description::verify_ssl%]"
|
||||
"verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]"
|
||||
},
|
||||
"description": "Use the following form to reconfigure your Firefly III instance.",
|
||||
"title": "Reconfigure Firefly III Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key for authenticating with Firefly III",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key_description%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "Verify the SSL certificate of the Firefly III instance"
|
||||
"verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]"
|
||||
},
|
||||
"description": "You can create an API key in the Firefly III UI. Go to **Options > Remote access and tokens**. Create a new personal access token and copy it (it will only display once)."
|
||||
"description": "You can create an access token in the Firefly III UI. Go to **Options > Remote access and tokens**. Create a new **personal access token** and copy it (it will only display once)."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from afsapi import (
|
||||
AFSAPI,
|
||||
FSApiError,
|
||||
FSConnectionError,
|
||||
FSNotImplementedError,
|
||||
PlayCaps,
|
||||
@@ -24,6 +27,7 @@ from homeassistant.components.media_player import (
|
||||
RepeatMode,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -35,6 +39,37 @@ from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fs_command_exception_wrap[
|
||||
_AFSAPIDeviceT: AFSAPIDevice,
|
||||
**_P,
|
||||
_R,
|
||||
](
|
||||
func: Callable[Concatenate[_AFSAPIDeviceT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_AFSAPIDeviceT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Wrap command methods and map API exceptions to HA errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def _wrap(self: _AFSAPIDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except FSConnectionError as err:
|
||||
command = func.__name__.removeprefix("async_")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={"command": command},
|
||||
) from err
|
||||
except FSApiError as err:
|
||||
command = func.__name__.removeprefix("async_")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"command": command, "message": str(err)},
|
||||
) from err
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: FrontierSiliconConfigEntry,
|
||||
@@ -272,14 +307,17 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
|
||||
# Management actions
|
||||
# power control
|
||||
@fs_command_exception_wrap
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the device."""
|
||||
await self.fs_device.set_power(True)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the device."""
|
||||
await self.fs_device.set_power(False)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if (await self.fs_device.get_play_state()) == PlayState.STOPPED:
|
||||
@@ -289,45 +327,54 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
else:
|
||||
await self.fs_device.play()
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self.fs_device.pause()
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self.fs_device.stop()
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command (results in rewind)."""
|
||||
await self.fs_device.rewind()
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command (results in fast-forward)."""
|
||||
await self.fs_device.forward()
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
await self.fs_device.set_mute(mute)
|
||||
|
||||
# volume
|
||||
@fs_command_exception_wrap
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Send volume up command."""
|
||||
volume = await self.fs_device.get_volume()
|
||||
volume = int(volume or 0) + 1
|
||||
await self.fs_device.set_volume(min(volume, self._max_volume or 1))
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
volume = await self.fs_device.get_volume()
|
||||
volume = int(volume or 0) - 1
|
||||
await self.fs_device.set_volume(max(volume, 0))
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume command."""
|
||||
if self._max_volume: # Can't do anything sensible if not set
|
||||
volume = int(volume * self._max_volume)
|
||||
await self.fs_device.set_volume(volume)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
await self.fs_device.set_power(True)
|
||||
@@ -337,6 +384,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
):
|
||||
await self.fs_device.set_mode(mode)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||
"""Select EQ Preset."""
|
||||
if (
|
||||
@@ -345,6 +393,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
):
|
||||
await self.fs_device.set_eq_preset(mode)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set repeat mode."""
|
||||
await self.fs_device.play_repeat(
|
||||
@@ -355,10 +404,12 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
}.get(repeat, PlayRepeatMode.OFF)
|
||||
)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Set shuffle mode."""
|
||||
await self.fs_device.set_play_shuffle(shuffle)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to a position in seconds."""
|
||||
await self.fs_device.set_play_position(int(position * 1000))
|
||||
@@ -374,6 +425,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
|
||||
return await browse_node(self.fs_device, media_content_type, media_content_id)
|
||||
|
||||
@fs_command_exception_wrap
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
|
||||
@@ -33,5 +33,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Failed to execute {command}: {message}"
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Failed to execute {command}: could not connect to device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"requirements": [
|
||||
"google-cloud-texttospeech==2.25.1",
|
||||
"google-cloud-speech==2.31.1"
|
||||
"google-cloud-texttospeech==2.36.0",
|
||||
"google-cloud-speech==2.38.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_pubsub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["google-cloud-pubsub==2.29.0"]
|
||||
"requirements": ["google-cloud-pubsub==2.37.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google", "homeassistant.helpers.location"],
|
||||
"requirements": ["google-maps-routing==0.6.15"]
|
||||
"requirements": ["google-maps-routing==0.10.0"]
|
||||
}
|
||||
|
||||
@@ -494,6 +494,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||
|
||||
async def _async_wait_push_loop(self) -> None:
|
||||
"""Wait for data push from server."""
|
||||
idle: asyncio.Future | None = None
|
||||
while True:
|
||||
try:
|
||||
self.number_of_messages = await self._async_fetch_number_of_messages()
|
||||
@@ -527,8 +528,9 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||
else:
|
||||
self.auth_errors = 0
|
||||
self.async_set_updated_data(self.number_of_messages)
|
||||
|
||||
try:
|
||||
idle: asyncio.Future = await self.imap_client.idle_start()
|
||||
idle = await self.imap_client.idle_start()
|
||||
await self.imap_client.wait_server_push()
|
||||
self.imap_client.idle_done()
|
||||
async with asyncio.timeout(10):
|
||||
@@ -543,6 +545,24 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||
await self._cleanup()
|
||||
await asyncio.sleep(BACKOFF_TIME)
|
||||
|
||||
finally:
|
||||
# Ensure no pending IDLE future survives
|
||||
if idle is not None and not idle.done():
|
||||
idle.cancel()
|
||||
_LOGGER.debug(
|
||||
"Canceling IDLE wait for %s",
|
||||
self.config_entry.data[CONF_SERVER],
|
||||
)
|
||||
try:
|
||||
await idle
|
||||
except asyncio.CancelledError:
|
||||
if (
|
||||
current_task := asyncio.current_task()
|
||||
) and current_task.cancelling():
|
||||
raise
|
||||
except AioImapException:
|
||||
pass
|
||||
|
||||
async def shutdown(self, *_: Any) -> None:
|
||||
"""Close resources."""
|
||||
if self._push_wait_task:
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["thinqconnect"],
|
||||
"requirements": ["thinqconnect==1.0.11"]
|
||||
"requirements": ["thinqconnect==1.0.12"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ from .const import (
|
||||
ATTR_HARDWARE_VERSION,
|
||||
ATTR_SOFTWARE_VERSION,
|
||||
CONF_AUTO_DISCOVERED,
|
||||
CONF_OVERRIDE_TYPE,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
NOBO_MANUFACTURER,
|
||||
@@ -115,3 +116,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) ->
|
||||
await entry.runtime_data.stop()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
# Lowercase override_type to match translation keys.
|
||||
new_options = dict(entry.options)
|
||||
if (override_type := new_options.get(CONF_OVERRIDE_TYPE)) is not None:
|
||||
new_options[CONF_OVERRIDE_TYPE] = override_type.lower()
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, options=new_options, version=1, minor_version=2
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from . import NoboHubConfigEntry
|
||||
from .const import (
|
||||
@@ -35,6 +36,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Nobø Ecohub."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
@@ -205,8 +207,11 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OVERRIDE_TYPE, default=override_type): vol.In(
|
||||
[OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW]
|
||||
vol.Required(CONF_OVERRIDE_TYPE, default=override_type): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW],
|
||||
translation_key=CONF_OVERRIDE_TYPE,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ DOMAIN = "nobo_hub"
|
||||
CONF_AUTO_DISCOVERED = "auto_discovered"
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_OVERRIDE_TYPE = "override_type"
|
||||
OVERRIDE_TYPE_CONSTANT = "Constant"
|
||||
OVERRIDE_TYPE_NOW = "Now"
|
||||
OVERRIDE_TYPE_CONSTANT = "constant"
|
||||
OVERRIDE_TYPE_NOW = "now"
|
||||
|
||||
NOBO_MANUFACTURER = "Glen Dimplex Nordic AS"
|
||||
ATTR_HARDWARE_VERSION = "hardware_version"
|
||||
|
||||
@@ -75,10 +75,18 @@
|
||||
"override_type": "Override type"
|
||||
},
|
||||
"data_description": {
|
||||
"override_type": "Select `Now` to end the override on the next week profile change, or `Constant` to keep it until manually cleared."
|
||||
"override_type": "Select \"Now\" to end the override on the next week profile change, or \"Constant\" to keep it until manually cleared."
|
||||
},
|
||||
"description": "Configure how overrides are ended."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"override_type": {
|
||||
"options": {
|
||||
"constant": "Constant",
|
||||
"now": "Now"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ upload:
|
||||
config_entry:
|
||||
integration: onedrive
|
||||
filename:
|
||||
required: false
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
text:
|
||||
multiple: true
|
||||
destination_folder:
|
||||
required: true
|
||||
selector:
|
||||
|
||||
@@ -143,24 +143,24 @@
|
||||
},
|
||||
"services": {
|
||||
"upload": {
|
||||
"description": "Uploads files to OneDrive.",
|
||||
"description": "Uploads one or more files to OneDrive.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "The config entry representing the OneDrive you want to upload to.",
|
||||
"name": "Config entry ID"
|
||||
},
|
||||
"destination_folder": {
|
||||
"description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.",
|
||||
"description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the files to. Will be created if it does not exist.",
|
||||
"example": "photos/snapshots",
|
||||
"name": "Destination folder"
|
||||
},
|
||||
"filename": {
|
||||
"description": "Path to the file to upload.",
|
||||
"description": "One or more paths to files to upload.",
|
||||
"example": "{example_image_path}",
|
||||
"name": "Filename"
|
||||
"name": "Filenames"
|
||||
}
|
||||
},
|
||||
"name": "Upload file"
|
||||
"name": "Upload files"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,8 +641,8 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
and isinstance(last_message["content"], str)
|
||||
)
|
||||
last_message["content"] = [
|
||||
{"type": "input_text", "text": last_message["content"]}, # type: ignore[list-item]
|
||||
*files, # type: ignore[list-item]
|
||||
{"type": "input_text", "text": last_message["content"]},
|
||||
*files,
|
||||
]
|
||||
|
||||
if structure and structure_name:
|
||||
|
||||
@@ -23,13 +23,7 @@ from .const import (
|
||||
PLATFORMS,
|
||||
TUYA_CLIENT_ID,
|
||||
)
|
||||
from .coordinator import (
|
||||
DeviceListener,
|
||||
HomeAssistantTuyaData,
|
||||
TokenListener,
|
||||
TuyaConfigEntry,
|
||||
create_manager,
|
||||
)
|
||||
from .coordinator import DeviceListener, TokenListener, TuyaConfigEntry, create_manager
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -71,8 +65,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool
|
||||
raise ConfigEntryAuthFailed(msg) from exc
|
||||
raise
|
||||
|
||||
# Connection is successful, store the manager & listener
|
||||
entry.runtime_data = HomeAssistantTuyaData(manager=manager, listener=listener)
|
||||
# Connection is successful, store the listener in runtime_data
|
||||
entry.runtime_data = listener
|
||||
|
||||
# Cleanup device registry
|
||||
await cleanup_device_registry(hass, manager, entry)
|
||||
@@ -126,10 +120,11 @@ async def cleanup_device_registry(
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
|
||||
"""Unloading the Tuya platforms."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
tuya = entry.runtime_data
|
||||
if tuya.manager.mq is not None:
|
||||
tuya.manager.mq.stop()
|
||||
tuya.manager.remove_device_listener(tuya.listener)
|
||||
listener = entry.runtime_data
|
||||
manager = listener.manager
|
||||
if manager.mq is not None:
|
||||
manager.mq.stop()
|
||||
manager.remove_device_listener(listener)
|
||||
return unload_ok
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for Tuya Smart devices."""
|
||||
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any
|
||||
|
||||
from tuya_sharing import (
|
||||
CustomerDevice,
|
||||
@@ -26,14 +26,7 @@ from .const import (
|
||||
TUYA_HA_SIGNAL_UPDATE_ENTITY,
|
||||
)
|
||||
|
||||
type TuyaConfigEntry = ConfigEntry[HomeAssistantTuyaData]
|
||||
|
||||
|
||||
class HomeAssistantTuyaData(NamedTuple):
|
||||
"""Tuya data stored in the Home Assistant data object."""
|
||||
|
||||
manager: Manager
|
||||
listener: SharingDeviceListener
|
||||
type TuyaConfigEntry = ConfigEntry[DeviceListener]
|
||||
|
||||
|
||||
def create_manager(entry: TuyaConfigEntry, token_listener: TokenListener) -> Manager:
|
||||
|
||||
@@ -58,9 +58,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) ->
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_LOGGER.debug("async_unload_entry called for entry: %s", entry.entry_id)
|
||||
hub: Hub | None = getattr(entry, "runtime_data", None)
|
||||
hub = entry.runtime_data
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok and hub is not None:
|
||||
if unload_ok:
|
||||
await hub.stop()
|
||||
hub.unregister_all_new_metric_callbacks()
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Diagnostics support for victron_gx."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_INSTALLATION_ID, CONF_SERIAL
|
||||
from .hub import VictronGxConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_SERIAL, CONF_INSTALLATION_ID}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: VictronGxConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
hub = entry.runtime_data
|
||||
merged_config = {**entry.data, **entry.options}
|
||||
return {
|
||||
"entry_data": async_redact_data(merged_config, TO_REDACT),
|
||||
"devices": hub.get_diagnostics_data(),
|
||||
}
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from victron_mqtt import (
|
||||
AuthenticationError,
|
||||
@@ -13,7 +13,9 @@ from victron_mqtt import (
|
||||
Hub as VictronVenusHub,
|
||||
Metric as VictronVenusMetric,
|
||||
MetricKind,
|
||||
MetricType,
|
||||
OperationMode,
|
||||
VictronEnum,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -140,6 +142,33 @@ class Hub:
|
||||
device_info["via_device"] = (DOMAIN, f"{installation_id}_system_0")
|
||||
return device_info
|
||||
|
||||
def get_diagnostics_data(self) -> dict[str, Any]:
|
||||
"""Return diagnostics data for the hub's device and entity tree."""
|
||||
return {
|
||||
device_id: {
|
||||
"name": device.name,
|
||||
"model": device.model,
|
||||
"manufacturer": device.manufacturer,
|
||||
"firmware_version": device.firmware_version,
|
||||
"device_type": device.device_type.string,
|
||||
"metrics": {
|
||||
metric.short_id: {
|
||||
"name": metric.name,
|
||||
"value": "**REDACTED**"
|
||||
if metric.metric_type == MetricType.LOCATION
|
||||
else metric.value
|
||||
if not isinstance(metric.value, VictronEnum)
|
||||
else metric.value.id,
|
||||
"unit": metric.unit_of_measurement,
|
||||
"kind": metric.metric_kind.name,
|
||||
"type": metric.metric_type.name,
|
||||
}
|
||||
for metric in device.metrics
|
||||
},
|
||||
}
|
||||
for device_id, device in self._hub.devices.items()
|
||||
}
|
||||
|
||||
def register_new_metric_callback(
|
||||
self, kind: MetricKind, new_metric_callback: NewMetricCallback
|
||||
) -> None:
|
||||
|
||||
@@ -42,7 +42,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
|
||||
@@ -26,9 +26,8 @@ from homeassistant.loader import async_get_homekit, async_get_zeroconf
|
||||
from homeassistant.setup import async_when_setup_or_start
|
||||
|
||||
from . import websocket_api
|
||||
from .const import DOMAIN, ZEROCONF_TYPE
|
||||
from .const import DATA_DISCOVERY, DATA_INSTANCE, DOMAIN, ZEROCONF_TYPE
|
||||
from .discovery import ( # noqa: F401
|
||||
DATA_DISCOVERY,
|
||||
ZeroconfDiscovery,
|
||||
build_homekit_model_lookups,
|
||||
info_from_service,
|
||||
@@ -89,8 +88,8 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf:
|
||||
|
||||
|
||||
def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
|
||||
if DOMAIN in hass.data:
|
||||
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
|
||||
if DATA_INSTANCE in hass.data:
|
||||
return hass.data[DATA_INSTANCE]
|
||||
|
||||
zeroconf = HaZeroconf(**_async_get_zc_args(hass))
|
||||
aio_zc = HaAsyncZeroconf(zc=zeroconf)
|
||||
@@ -104,7 +103,7 @@ def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
|
||||
# Wait to the close event to shutdown zeroconf to give
|
||||
# integrations time to send a good bye message
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_zeroconf)
|
||||
hass.data[DOMAIN] = aio_zc
|
||||
hass.data[DATA_INSTANCE] = aio_zc
|
||||
|
||||
return aio_zc
|
||||
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"""Zeroconf constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .discovery import ZeroconfDiscovery
|
||||
from .models import HaAsyncZeroconf
|
||||
|
||||
DOMAIN = "zeroconf"
|
||||
|
||||
ZEROCONF_TYPE = "_home-assistant._tcp.local."
|
||||
|
||||
REQUEST_TIMEOUT = 10000 # 10 seconds
|
||||
|
||||
DATA_INSTANCE: HassKey[HaAsyncZeroconf] = HassKey(DOMAIN)
|
||||
DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey(f"{DOMAIN}_discovery")
|
||||
|
||||
@@ -24,12 +24,9 @@ from homeassistant.helpers.service_info.zeroconf import (
|
||||
ZeroconfServiceInfo as _ZeroconfServiceInfo,
|
||||
)
|
||||
from homeassistant.loader import HomeKitDiscoveredIntegration, ZeroconfMatcher
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN, REQUEST_TIMEOUT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import HaZeroconf
|
||||
from .models import HaZeroconf
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,9 +50,6 @@ ATTR_PROPERTIES: Final = "properties"
|
||||
DUPLICATE_INSTANCE_ID_ISSUE_ID = "duplicate_instance_id"
|
||||
|
||||
|
||||
DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery")
|
||||
|
||||
|
||||
def build_homekit_model_lookups(
|
||||
homekit_models: dict[str, HomeKitDiscoveredIntegration],
|
||||
) -> tuple[
|
||||
|
||||
@@ -17,8 +17,8 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
|
||||
from .const import DOMAIN, REQUEST_TIMEOUT
|
||||
from .discovery import DATA_DISCOVERY, ZeroconfDiscovery
|
||||
from .const import DATA_DISCOVERY, DATA_INSTANCE, REQUEST_TIMEOUT
|
||||
from .discovery import ZeroconfDiscovery
|
||||
from .models import HaAsyncZeroconf
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -157,7 +157,7 @@ async def ws_subscribe_discovery(
|
||||
) -> None:
|
||||
"""Handle subscribe advertisements websocket command."""
|
||||
discovery = hass.data[DATA_DISCOVERY]
|
||||
aiozc: HaAsyncZeroconf = hass.data[DOMAIN]
|
||||
aiozc = hass.data[DATA_INSTANCE]
|
||||
await _DiscoverySubscription(
|
||||
hass, connection, msg["id"], aiozc, discovery
|
||||
).async_start()
|
||||
|
||||
Generated
+4
@@ -173,6 +173,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "dlink",
|
||||
"hostname": "dsp-w215",
|
||||
},
|
||||
{
|
||||
"domain": "duco",
|
||||
"hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]",
|
||||
},
|
||||
{
|
||||
"domain": "elgato",
|
||||
"registered_devices": True,
|
||||
|
||||
@@ -149,7 +149,7 @@ iso4217!=1.10.20220401
|
||||
|
||||
# protobuf must be in package constraints for the wheel
|
||||
# builder to build binary wheels
|
||||
protobuf==6.32.0
|
||||
protobuf==7.34.1
|
||||
|
||||
# faust-cchardet: Ensure we have a version we can build wheels
|
||||
# 2.1.18 is the first version that works with our wheel builder
|
||||
|
||||
Generated
+6
-6
@@ -1107,19 +1107,19 @@ goodwe==0.4.10
|
||||
google-api-python-client==2.71.0
|
||||
|
||||
# homeassistant.components.google_pubsub
|
||||
google-cloud-pubsub==2.29.0
|
||||
google-cloud-pubsub==2.37.0
|
||||
|
||||
# homeassistant.components.google_cloud
|
||||
google-cloud-speech==2.31.1
|
||||
google-cloud-speech==2.38.0
|
||||
|
||||
# homeassistant.components.google_cloud
|
||||
google-cloud-texttospeech==2.25.1
|
||||
google-cloud-texttospeech==2.36.0
|
||||
|
||||
# homeassistant.components.google_generative_ai_conversation
|
||||
google-genai==1.59.0
|
||||
|
||||
# homeassistant.components.google_travel_time
|
||||
google-maps-routing==0.6.15
|
||||
google-maps-routing==0.10.0
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==9.1.2
|
||||
@@ -2578,7 +2578,7 @@ python-digitalocean==1.13.2
|
||||
python-dropbox-api==0.1.3
|
||||
|
||||
# homeassistant.components.duco
|
||||
python-duco-client==0.3.2
|
||||
python-duco-client==0.3.4
|
||||
|
||||
# homeassistant.components.ecobee
|
||||
python-ecobee-api==0.3.2
|
||||
@@ -3116,7 +3116,7 @@ thermopro-ble==1.1.3
|
||||
thingspeak==1.0.0
|
||||
|
||||
# homeassistant.components.lg_thinq
|
||||
thinqconnect==1.0.11
|
||||
thinqconnect==1.0.12
|
||||
|
||||
# homeassistant.components.tikteck
|
||||
tikteck==0.4
|
||||
|
||||
@@ -14,7 +14,7 @@ freezegun==1.5.5
|
||||
librt==0.8.1
|
||||
license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy==1.20.1
|
||||
mypy==1.20.2
|
||||
prek==0.2.28
|
||||
pydantic==2.13.2
|
||||
pylint==4.0.5
|
||||
@@ -43,7 +43,7 @@ types-caldav==1.3.0.20250516
|
||||
types-chardet==0.1.5
|
||||
types-decorator==5.2.0.20260408
|
||||
types-pexpect==4.9.0.20260408
|
||||
types-protobuf==6.32.1.20260221
|
||||
types-protobuf==7.34.1.20260408
|
||||
types-psutil==7.2.2.20260408
|
||||
types-pyserial==3.5.0.20260408
|
||||
types-python-dateutil==2.9.0.20260408
|
||||
|
||||
Generated
+6
-6
@@ -986,19 +986,19 @@ goodwe==0.4.10
|
||||
google-api-python-client==2.71.0
|
||||
|
||||
# homeassistant.components.google_pubsub
|
||||
google-cloud-pubsub==2.29.0
|
||||
google-cloud-pubsub==2.37.0
|
||||
|
||||
# homeassistant.components.google_cloud
|
||||
google-cloud-speech==2.31.1
|
||||
google-cloud-speech==2.38.0
|
||||
|
||||
# homeassistant.components.google_cloud
|
||||
google-cloud-texttospeech==2.25.1
|
||||
google-cloud-texttospeech==2.36.0
|
||||
|
||||
# homeassistant.components.google_generative_ai_conversation
|
||||
google-genai==1.59.0
|
||||
|
||||
# homeassistant.components.google_travel_time
|
||||
google-maps-routing==0.6.15
|
||||
google-maps-routing==0.10.0
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==9.1.2
|
||||
@@ -2204,7 +2204,7 @@ python-citybikes==0.3.3
|
||||
python-dropbox-api==0.1.3
|
||||
|
||||
# homeassistant.components.duco
|
||||
python-duco-client==0.3.2
|
||||
python-duco-client==0.3.4
|
||||
|
||||
# homeassistant.components.ecobee
|
||||
python-ecobee-api==0.3.2
|
||||
@@ -2643,7 +2643,7 @@ thermobeacon-ble==0.10.0
|
||||
thermopro-ble==1.1.3
|
||||
|
||||
# homeassistant.components.lg_thinq
|
||||
thinqconnect==1.0.11
|
||||
thinqconnect==1.0.12
|
||||
|
||||
# homeassistant.components.tilt_ble
|
||||
tilt-ble==1.0.1
|
||||
|
||||
@@ -139,7 +139,7 @@ iso4217!=1.10.20220401
|
||||
|
||||
# protobuf must be in package constraints for the wheel
|
||||
# builder to build binary wheels
|
||||
protobuf==6.32.0
|
||||
protobuf==7.34.1
|
||||
|
||||
# faust-cchardet: Ensure we have a version we can build wheels
|
||||
# 2.1.18 is the first version that works with our wheel builder
|
||||
|
||||
@@ -10,10 +10,11 @@ from duco.models import LanInfo
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.duco.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .conftest import TEST_HOST, TEST_MAC, USER_INPUT
|
||||
@@ -30,6 +31,12 @@ ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
|
||||
properties={},
|
||||
)
|
||||
|
||||
DHCP_DISCOVERY = DhcpServiceInfo(
|
||||
ip=TEST_HOST,
|
||||
hostname="duco_ddeeff",
|
||||
macaddress="aabbccddeeff",
|
||||
)
|
||||
|
||||
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
@@ -313,3 +320,137 @@ async def test_reconfigure_flow_error(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
|
||||
async def test_dhcp_discovery_new_device(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test DHCP discovery of a new device shows confirmation form and creates entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DHCP_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
assert result["description_placeholders"] == {"name": "SILENT_CONNECT"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "SILENT_CONNECT"
|
||||
assert result["data"] == USER_INPUT
|
||||
assert result["result"].unique_id == TEST_MAC
|
||||
|
||||
|
||||
async def test_dhcp_discovery_updates_host(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test DHCP discovery updates the host of an existing entry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
new_ip = "192.168.1.200"
|
||||
discovery = DhcpServiceInfo(
|
||||
ip=new_ip,
|
||||
hostname="duco_ddeeff",
|
||||
macaddress="aabbccddeeff",
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=discovery,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert mock_config_entry.data[CONF_HOST] == new_ip
|
||||
|
||||
|
||||
async def test_dhcp_discovery_already_configured_same_ip(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test DHCP discovery with unchanged IP aborts as already_configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DHCP_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_reason"),
|
||||
[
|
||||
(DucoConnectionError("Connection refused"), "cannot_connect"),
|
||||
(DucoError("Unexpected error"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_dhcp_discovery_exceptions(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
exception: Exception,
|
||||
expected_reason: str,
|
||||
) -> None:
|
||||
"""Test DHCP discovery aborts on connection and unknown errors."""
|
||||
mock_duco_client.async_get_board_info.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DHCP_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == expected_reason
|
||||
|
||||
|
||||
async def test_dhcp_discovery_exception_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test DHCP discovery recovers after an initial exception and creates the entry."""
|
||||
mock_duco_client.async_get_board_info.side_effect = DucoConnectionError(
|
||||
"Connection refused"
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DHCP_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
mock_duco_client.async_get_board_info.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DHCP_DISCOVERY,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].unique_id == TEST_MAC
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Test the Frontier Silicon media player entity."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from afsapi import FSConnectionError, FSNotImplementedError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.frontier_silicon.media_player import AFSAPIDevice
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "translation_key", "message"),
|
||||
[
|
||||
(FSConnectionError("Connection failed"), "connection_error", None),
|
||||
(
|
||||
FSNotImplementedError("Command is not implemented"),
|
||||
"api_error",
|
||||
"Command is not implemented",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_async_media_previous_track_maps_errors(
|
||||
error: Exception, translation_key: str, message: str | None
|
||||
) -> None:
|
||||
"""Test previous track maps API failures to Home Assistant errors."""
|
||||
fs_device = AsyncMock()
|
||||
fs_device.rewind.side_effect = error
|
||||
entity = AFSAPIDevice("unique_id", "name", fs_device)
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await entity.async_media_previous_track()
|
||||
|
||||
assert exc_info.value.translation_key == translation_key
|
||||
assert exc_info.value.translation_placeholders["command"] == "media_previous_track"
|
||||
|
||||
assert (
|
||||
message is None or message in exc_info.value.translation_placeholders["message"]
|
||||
)
|
||||
@@ -538,6 +538,7 @@ async def test_lost_connection_with_imap_push(
|
||||
) -> None:
|
||||
"""Test error handling when the connection is lost."""
|
||||
# Mock an error in waiting for a pushed update
|
||||
mock_imap_protocol.idle_start.return_value = asyncio.Future()
|
||||
mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -550,6 +551,10 @@ async def test_lost_connection_with_imap_push(
|
||||
assert state is not None
|
||||
assert state.state == "0"
|
||||
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=30))
|
||||
await hass.async_block_till_done()
|
||||
assert "Canceling IDLE wait for imap.server.com" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"])
|
||||
async def test_fetch_number_of_messages(
|
||||
|
||||
@@ -276,7 +276,7 @@ async def test_options_flow(
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_OVERRIDE_TYPE: "Constant",
|
||||
CONF_OVERRIDE_TYPE: "constant",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -284,7 +284,7 @@ async def test_options_flow(
|
||||
assert mock_unload_entry.await_count == 1
|
||||
assert mock_setup_entry.await_count == 1
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert config_entry.options == {CONF_OVERRIDE_TYPE: "Constant"}
|
||||
assert config_entry.options == {CONF_OVERRIDE_TYPE: "constant"}
|
||||
mock_unload_entry.reset_mock()
|
||||
mock_setup_entry.reset_mock()
|
||||
|
||||
@@ -292,7 +292,7 @@ async def test_options_flow(
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_OVERRIDE_TYPE: "Now",
|
||||
CONF_OVERRIDE_TYPE: "now",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -300,4 +300,4 @@ async def test_options_flow(
|
||||
assert mock_unload_entry.await_count == 1
|
||||
assert mock_setup_entry.await_count == 1
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert config_entry.options == {CONF_OVERRIDE_TYPE: "Now"}
|
||||
assert config_entry.options == {CONF_OVERRIDE_TYPE: "now"}
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from homeassistant.components.nobo_hub import async_setup_entry
|
||||
from homeassistant.components.nobo_hub.const import (
|
||||
CONF_AUTO_DISCOVERED,
|
||||
CONF_OVERRIDE_TYPE,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -175,6 +176,71 @@ async def test_setup_autodiscovered_rediscovery_failure(
|
||||
assert exc_info.value.translation_placeholders == expected_placeholders
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored_value", "expected_value"),
|
||||
[
|
||||
("Constant", "constant"),
|
||||
("Now", "now"),
|
||||
("constant", "constant"),
|
||||
],
|
||||
)
|
||||
async def test_migrate_options_lowercases_override_type(
|
||||
hass: HomeAssistant,
|
||||
stored_value: str,
|
||||
expected_value: str,
|
||||
) -> None:
|
||||
"""Legacy capitalized override_type values are lowercased on migration."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="My Eco Hub",
|
||||
unique_id=SERIAL,
|
||||
data={
|
||||
CONF_SERIAL: SERIAL,
|
||||
CONF_IP_ADDRESS: STORED_IP,
|
||||
CONF_AUTO_DISCOVERED: False,
|
||||
},
|
||||
options={CONF_OVERRIDE_TYPE: stored_value},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
hub = _make_hub_mock()
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.return_value = hub
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value=set())
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.minor_version == 2
|
||||
assert entry.options == {CONF_OVERRIDE_TYPE: expected_value}
|
||||
|
||||
|
||||
async def test_migrate_options_without_override_type(hass: HomeAssistant) -> None:
|
||||
"""Migration still bumps the version when no override_type is stored."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="My Eco Hub",
|
||||
unique_id=SERIAL,
|
||||
data={
|
||||
CONF_SERIAL: SERIAL,
|
||||
CONF_IP_ADDRESS: STORED_IP,
|
||||
CONF_AUTO_DISCOVERED: False,
|
||||
},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
hub = _make_hub_mock()
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.return_value = hub
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value=set())
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.minor_version == 2
|
||||
assert entry.options == {}
|
||||
|
||||
|
||||
async def test_setup_registers_hub_device(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import NamedTuple
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyoverkiz.client import OverkizClient
|
||||
@@ -20,6 +21,14 @@ from tests.common import MockConfigEntry
|
||||
type SetupOverkizIntegration = Callable[..., Awaitable[MockConfigEntry]]
|
||||
|
||||
|
||||
class FixtureDevice(NamedTuple):
|
||||
"""Test device binding a fixture file to a device URL and entity id."""
|
||||
|
||||
fixture: str
|
||||
device_url: str
|
||||
entity_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockOverkizClient(OverkizClient):
|
||||
"""Mock Overkiz client used by integration tests."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -33,20 +33,11 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MockOverkizClient, SetupOverkizIntegration
|
||||
from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration
|
||||
from .helpers import assert_command_call, async_deliver_events, build_event
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
class FixtureDevice(NamedTuple):
|
||||
"""Test device binding a fixture file to a device URL and entity id."""
|
||||
|
||||
fixture: str
|
||||
device_url: str
|
||||
entity_id: str
|
||||
|
||||
|
||||
AWNING = FixtureDevice(
|
||||
"setup/local_somfy_connexoon_europe.json",
|
||||
"io://1234-1234-1234/5928357",
|
||||
|
||||
@@ -24,7 +24,7 @@ async def test_diagnostics(
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
diagnostic_data = await async_load_json_object_fixture(
|
||||
hass, "setup/setup_tahoma_switch.json", DOMAIN
|
||||
hass, "diagnostics/cloud_somfy_tahoma_switch_europe.json", DOMAIN
|
||||
)
|
||||
|
||||
with patch.multiple(
|
||||
@@ -47,7 +47,7 @@ async def test_device_diagnostics(
|
||||
) -> None:
|
||||
"""Test device diagnostics."""
|
||||
diagnostic_data = await async_load_json_object_fixture(
|
||||
hass, "setup/setup_tahoma_switch.json", DOMAIN
|
||||
hass, "diagnostics/cloud_somfy_tahoma_switch_europe.json", DOMAIN
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
@@ -75,9 +75,8 @@ async def test_device_diagnostics_execution_history_subsystem(
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test execution history matching ignores subsystem suffix."""
|
||||
|
||||
diagnostic_data = await async_load_json_object_fixture(
|
||||
hass, "setup/setup_tahoma_switch.json", DOMAIN
|
||||
hass, "diagnostics/cloud_somfy_tahoma_switch_europe.json", DOMAIN
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -16,20 +15,11 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MockOverkizClient, SetupOverkizIntegration
|
||||
from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration
|
||||
from .helpers import async_deliver_events, build_event
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
class FixtureDevice(NamedTuple):
|
||||
"""Test device binding a fixture file to a device URL and entity id."""
|
||||
|
||||
fixture: str
|
||||
device_url: str
|
||||
entity_id: str
|
||||
|
||||
|
||||
TEMPERATURE_SENSOR = FixtureDevice(
|
||||
"setup/cloud_nexity_rail_din_europe.json",
|
||||
"io://1234-5678-1698/15702199#2",
|
||||
@@ -53,8 +43,8 @@ HOMEKIT_STACK = FixtureDevice(
|
||||
# Device with core:MeasuredValueType attribute (test for dynamic unit resolution)
|
||||
COZYTOUCH_DHW = FixtureDevice(
|
||||
"setup/cloud_atlantic_cozytouch.json",
|
||||
"io://1234-5678-5643/109286#1",
|
||||
"sensor.patio_water_heating_temperature",
|
||||
"io://1234-5678-5643/109286#2",
|
||||
"sensor.patio_water_heating_office_energy_meter_electric_energy_consumption",
|
||||
)
|
||||
|
||||
SNAPSHOT_FIXTURES = [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# serializer version: 1
|
||||
# name: test_diagnostics
|
||||
dict({
|
||||
'devices': dict({
|
||||
'battery_0': dict({
|
||||
'device_type': 'Battery',
|
||||
'firmware_version': None,
|
||||
'manufacturer': None,
|
||||
'metrics': dict({
|
||||
'battery_current': dict({
|
||||
'kind': 'SENSOR',
|
||||
'name': 'DC bus current',
|
||||
'type': 'CURRENT',
|
||||
'unit': 'A',
|
||||
'value': 10.5,
|
||||
}),
|
||||
}),
|
||||
'model': None,
|
||||
'name': 'Battery',
|
||||
}),
|
||||
'system_0': dict({
|
||||
'device_type': 'Victron Venus',
|
||||
'firmware_version': None,
|
||||
'manufacturer': None,
|
||||
'metrics': dict({
|
||||
'system_state': dict({
|
||||
'kind': 'SENSOR',
|
||||
'name': 'System state',
|
||||
'type': 'ENUM',
|
||||
'unit': None,
|
||||
'value': 'bulk',
|
||||
}),
|
||||
}),
|
||||
'model': None,
|
||||
'name': 'Victron Venus',
|
||||
}),
|
||||
}),
|
||||
'entry_data': dict({
|
||||
'host': '**REDACTED**',
|
||||
'installation_id': '**REDACTED**',
|
||||
'model': 'Cerbo GX',
|
||||
'password': '**REDACTED**',
|
||||
'port': 1883,
|
||||
'serial': '**REDACTED**',
|
||||
'ssl': False,
|
||||
'username': '**REDACTED**',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for victron_gx diagnostics."""
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from victron_mqtt import Hub as VictronVenusHub
|
||||
from victron_mqtt.testing import finalize_injection, inject_message
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import MOCK_INSTALLATION_ID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
init_integration: tuple[VictronVenusHub, MockConfigEntry],
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
victron_hub, config_entry = init_integration
|
||||
|
||||
latitude = "52.0907"
|
||||
longitude = "5.1214"
|
||||
|
||||
# Inject metrics so the device tree is populated and enum serialization is covered
|
||||
await inject_message(
|
||||
victron_hub,
|
||||
f"N/{MOCK_INSTALLATION_ID}/battery/0/Dc/0/Current",
|
||||
'{"value": 10.5}',
|
||||
)
|
||||
await inject_message(
|
||||
victron_hub,
|
||||
f"N/{MOCK_INSTALLATION_ID}/system/0/SystemState/State",
|
||||
'{"value": 3}',
|
||||
)
|
||||
await inject_message(
|
||||
victron_hub,
|
||||
f"N/{MOCK_INSTALLATION_ID}/gps/0/Position/Latitude",
|
||||
f'{{"value": {latitude}}}',
|
||||
)
|
||||
await inject_message(
|
||||
victron_hub,
|
||||
f"N/{MOCK_INSTALLATION_ID}/gps/0/Position/Longitude",
|
||||
f'{{"value": {longitude}}}',
|
||||
)
|
||||
|
||||
await finalize_injection(victron_hub)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
|
||||
serialized_result = repr(result)
|
||||
|
||||
assert latitude not in serialized_result
|
||||
assert longitude not in serialized_result
|
||||
assert "**REDACTED**" in serialized_result
|
||||
assert result == snapshot
|
||||
Reference in New Issue
Block a user