Compare commits

...

20 Commits

Author SHA1 Message Date
J. Nick Koston 30f8f0517f another 2026-04-22 12:58:51 +02:00
J. Nick Koston 3f31be37f5 another 2026-04-22 12:56:06 +02:00
J. Nick Koston cf0a14f92b another 2026-04-22 12:51:13 +02:00
J. Nick Koston 2fcbd50784 make the bot happy 2026-04-22 12:31:22 +02:00
J. Nick Koston c08743f907 bump 2026-04-22 12:29:10 +02:00
J. Nick Koston a67ea6d4f7 Bump protobuf to 7.34.1
changelog: http://github.com/protocolbuffers/protobuf/compare/v32.0...v34.1
2026-04-22 12:25:10 +02:00
Erwin Douna d17f6a1509 Firefly III consistency with access token (#168565) 2026-04-22 11:12:40 +02:00
Thijs W. f3932f2342 Improve exception handling for frontier_silicon (#168635)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-04-22 10:58:09 +02:00
Mick Vleeshouwer 598be31daf Improve test structure for Overkiz (#168728) 2026-04-22 10:10:18 +02:00
epenet 9b2a81614f Simplify Tuya runtime_data (#168718)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 10:02:24 +02:00
Øyvind Matheson Wergeland f53c89d3bc Translate override_type options in nobo_hub (#168752) 2026-04-22 09:59:51 +02:00
dependabot[bot] ac6991072f Bump github/codeql-action from 4.35.1 to 4.35.2 (#168754)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 09:53:11 +02:00
Jan Bouwhuis 018e8e06fa Cancel and await idle_start future if the task was canceled after an IMAP connection was lost (#168662)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-22 09:43:22 +02:00
Ronald van der Meer 0ffc9694a7 Bump python-duco-client to 0.3.4 (#168757) 2026-04-22 09:41:21 +02:00
Marc Mueller 8d8b30a41e Update mypy to 1.20.2 (#168741) 2026-04-22 09:38:08 +02:00
Tomer 9b7f61d862 Victron GX: Diagnostics (#168700)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-22 09:36:49 +02:00
epenet 368f2f44be Use HassKey in zeroconf (#168707)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:26:13 +02:00
LG-ThinQ-Integration ad6a910244 Bump thinqconnect to 1.0.12 (#168753)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-04-22 09:21:15 +02:00
Leonardo Rivera 840b44039d Fix OneDrive upload service to support multiple files (#168512) 2026-04-22 09:11:27 +02:00
Ronald van der Meer 1943675a64 Add DHCP discovery to Duco integration (#168730) 2026-04-22 08:32:05 +02:00
46 changed files with 666 additions and 124 deletions
+2 -2
View File
@@ -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:
+6 -1
View File
@@ -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"]
}
+21 -1
View File
@@ -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,
)
),
}
)
+2 -2
View File
@@ -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:
+8 -13
View File
@@ -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
+2 -9
View File
@@ -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(),
}
+30 -1
View File
@@ -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()
+4
View File
@@ -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,
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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
+142 -1
View File
@@ -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"]
)
+5
View File
@@ -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"}
+66
View File
@@ -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,
+9
View File
@@ -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."""
+2 -11
View File
@@ -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",
+3 -4
View File
@@ -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(
+3 -13
View File
@@ -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