Compare commits

..

17 Commits

Author SHA1 Message Date
Ludovic BOUÉ
7d97424a22 Remove invalid segment ID test from vacuum clean area tests 2026-02-19 23:20:55 +01:00
Ludovic BOUÉ
76114c9ded Remove redundant error handling for segment ID conversion in MatterVacuum 2026-02-19 23:20:02 +01:00
Ludovic BOUÉ
0334dad2f8 Add service area feature map tracking to MatterVacuum for improved state management 2026-02-19 23:19:10 +01:00
Ludovic BOUÉ
2dd65172b0 Refactor MatterVacuum to use property for current segments and simplify segment comparison logic 2026-02-19 23:17:08 +01:00
Ludovic BOUÉ
4532fd379e Add validation for segment IDs in Matter vacuum clean area action 2026-02-19 22:55:10 +01:00
Ludovic BOUÉ
578b2b3d43 Refactor Matter vacuum area selection to resolve run mode before sending commands 2026-02-19 22:52:31 +01:00
Ludovic BOUÉ
9bb2f56fbe Merge branch 'dev' into matter_clean_area 2026-02-19 22:40:31 +01:00
Ludovic BOUÉ
a7d209f1f5 Enhance Matter vacuum support for clean area by checking Map feature availability 2026-02-19 22:38:53 +01:00
Ludovic BOUÉ
83d73dce5c Add SupportedAreas as optional_attributes 2026-02-19 22:18:45 +01:00
Ludovic BOUÉ
d84f81daf2 Enhance vacuum tests to verify segment issue handling and update command assertions 2026-02-19 21:55:17 +01:00
Ludovic BOUÉ
79d4f5c8cf Refactor segment handling to add last_seen_segments 2026-02-19 21:53:54 +01:00
Ludovic BOUÉ
e9e1abb604 Remove debug log for area reset before cleaning
Removed debug logging for resetting selected areas.
2026-02-19 21:42:01 +01:00
Ludovic BOUÉ
9a97541253 Revert unwanted change 2026-02-19 21:39:19 +01:00
Ludovic BOUÉ
fd39f3c431 Add support for unconstrained area selection in vacuum
Reset selected areas for full clean operation when starting the vacuum.
2026-02-19 21:37:43 +01:00
Ludovic BOUÉ
2cc4a77746 Remove Segment group
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-02-19 20:02:41 +01:00
Ludovic BOUÉ
d7ef65e562 Add test for vacuum clean_area action and update supported features 2026-02-18 17:48:16 +00:00
Ludovic BOUÉ
e765c1652c Add support for cleaning specific segments in Matter vacuum 2026-02-18 17:45:05 +00:00
93 changed files with 719 additions and 841 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 3
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.3"

View File

@@ -64,6 +64,6 @@ class AtagSensor(AtagEntity, SensorEntity):
return self.coordinator.atag.report[self._id].state
@property
def icon(self) -> str:
def icon(self):
"""Return icon."""
return self.coordinator.atag.report[self._id].icon

View File

@@ -58,12 +58,11 @@ async def async_setup_entry(
class GeonetnzVolcanoSensor(SensorEntity):
"""Represents an external event with GeoNet NZ Volcano feed data."""
_attr_icon = DEFAULT_ICON
_attr_native_unit_of_measurement = "alert level"
_attr_should_poll = False
def __init__(self, config_entry_id, feed_manager, external_id, unit_system):
"""Initialize entity with data from feed entry."""
self._config_entry_id = config_entry_id
self._feed_manager = feed_manager
self._external_id = external_id
self._attr_unique_id = f"{config_entry_id}_{external_id}"
@@ -72,6 +71,8 @@ class GeonetnzVolcanoSensor(SensorEntity):
self._distance = None
self._latitude = None
self._longitude = None
self._attribution = None
self._alert_level = None
self._activity = None
self._hazards = None
self._feed_last_update = None
@@ -123,7 +124,7 @@ class GeonetnzVolcanoSensor(SensorEntity):
self._latitude = round(feed_entry.coordinates[0], 5)
self._longitude = round(feed_entry.coordinates[1], 5)
self._attr_attribution = feed_entry.attribution
self._attr_native_value = feed_entry.alert_level
self._alert_level = feed_entry.alert_level
self._activity = feed_entry.activity
self._hazards = feed_entry.hazards
self._feed_last_update = dt_util.as_utc(last_update) if last_update else None
@@ -132,10 +133,25 @@ class GeonetnzVolcanoSensor(SensorEntity):
)
@property
def name(self) -> str:
def native_value(self):
"""Return the state of the sensor."""
return self._alert_level
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
return DEFAULT_ICON
@property
def name(self) -> str | None:
"""Return the name of the entity."""
return f"Volcano {self._title}"
@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return "alert level"
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""

View File

@@ -4,16 +4,16 @@
"abort": {
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information.",
"not_hassio_thread": "The OpenThread Border Router app can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router app is already running, it cannot be installed again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router app. If you use the Thread network, make sure you have alternative border routers. Uninstall the app and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again."
},
"progress": {
"install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.",
"install_otbr_addon": "Installing app",
"start_otbr_addon": "Starting app"
"install_otbr_addon": "Installing add-on",
"start_otbr_addon": "Starting add-on"
},
"step": {
"confirm_otbr": {
@@ -34,7 +34,7 @@
"title": "Updating adapter"
},
"otbr_failed": {
"description": "The OpenThread Border Router app installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other apps, and try again. Check the Supervisor logs if the problem persists.",
"description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists.",
"title": "Failed to set up OpenThread Border Router"
},
"pick_firmware": {
@@ -89,11 +89,11 @@
"silabs_multiprotocol_hardware": {
"options": {
"abort": {
"addon_already_running": "Failed to start the {addon_name} app because it is already running.",
"addon_info_failed": "Failed to get {addon_name} app info.",
"addon_install_failed": "Failed to install the {addon_name} app.",
"addon_already_running": "Failed to start the {addon_name} add-on because it is already running.",
"addon_info_failed": "Failed to get {addon_name} add-on info.",
"addon_install_failed": "Failed to install the {addon_name} add-on.",
"addon_set_config_failed": "Failed to set {addon_name} configuration.",
"addon_start_failed": "Failed to start the {addon_name} app.",
"addon_start_failed": "Failed to start the {addon_name} add-on.",
"not_hassio": "The hardware options can only be configured on Home Assistant OS installations.",
"zha_migration_failed": "The ZHA migration did not succeed."
},
@@ -101,8 +101,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"progress": {
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
"install_addon": "Please wait while the {addon_name} add-on installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} add-on start completes. This may take some seconds."
},
"step": {
"addon_installed_other_device": {
@@ -129,7 +129,7 @@
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"install_addon": {
"title": "The Silicon Labs Multiprotocol app installation has started"
"title": "The Silicon Labs Multiprotocol add-on installation has started"
},
"notify_channel_change": {
"description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes.",
@@ -143,7 +143,7 @@
"title": "Reconfigure IEEE 802.15.4 radio multiprotocol support"
},
"start_addon": {
"title": "The Silicon Labs Multiprotocol app is starting."
"title": "The Silicon Labs Multiprotocol add-on is starting."
},
"uninstall_addon": {
"data": {

View File

@@ -3,7 +3,6 @@
from datetime import datetime
from functools import partial
import logging
from typing import Any
from pyhomematic import HMConnection
import voluptuous as vol
@@ -216,11 +215,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_CONF] = remotes = {}
hass.data[DATA_STORE] = set()
interfaces: dict[str, dict[str, Any]] = conf[CONF_INTERFACES]
hosts: dict[str, dict[str, Any]] = conf[CONF_HOSTS]
# Create hosts-dictionary for pyhomematic
for rname, rconfig in interfaces.items():
for rname, rconfig in conf[CONF_INTERFACES].items():
remotes[rname] = {
"ip": rconfig.get(CONF_HOST),
"port": rconfig.get(CONF_PORT),
@@ -236,7 +232,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"connect": True,
}
for sname, sconfig in hosts.items():
for sname, sconfig in conf[CONF_HOSTS].items():
remotes[sname] = {
"ip": sconfig.get(CONF_HOST),
"port": sconfig[CONF_PORT],
@@ -262,7 +258,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop)
# Init homematic hubs
entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in hosts]
entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in conf[CONF_HOSTS]]
def _hm_service_virtualkey(service: ServiceCall) -> None:
"""Service to handle virtualkey servicecalls."""
@@ -298,7 +294,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
def _service_handle_value(service: ServiceCall) -> None:
"""Service to call setValue method for HomeMatic system variable."""
entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID)
entity_ids = service.data.get(ATTR_ENTITY_ID)
name = service.data[ATTR_NAME]
value = service.data[ATTR_VALUE]

View File

@@ -11,7 +11,6 @@ from pyhomematic import HMConnection
from pyhomematic.devicetypes.generic import HMGeneric
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.event import track_time_interval
@@ -46,16 +45,15 @@ class HMDevice(Entity):
entity_description: EntityDescription | None = None,
) -> None:
"""Initialize a generic HomeMatic device."""
self._attr_name = config.get(ATTR_NAME)
self._name = config.get(ATTR_NAME)
self._address = config.get(ATTR_ADDRESS)
self._interface = config.get(ATTR_INTERFACE)
self._channel = config.get(ATTR_CHANNEL)
self._state = config.get(ATTR_PARAM)
if unique_id := config.get(ATTR_UNIQUE_ID):
self._attr_unique_id = unique_id.replace(" ", "_")
self._unique_id = config.get(ATTR_UNIQUE_ID)
self._data: dict[str, Any] = {}
self._connected = False
self._attr_available = False
self._available = False
self._channel_map: dict[str, str] = {}
if entity_description is not None:
@@ -69,6 +67,21 @@ class HMDevice(Entity):
"""Load data init callbacks."""
self._subscribe_homematic_events()
@property
def unique_id(self):
"""Return unique ID. HomeMatic entity IDs are unique by default."""
return self._unique_id.replace(" ", "_")
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def available(self) -> bool:
"""Return true if device is available."""
return self._available
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device specific state attributes."""
@@ -103,7 +116,7 @@ class HMDevice(Entity):
self._load_data_from_hm()
# Link events from pyhomematic
self._attr_available = not self._hmdevice.UNREACH
self._available = not self._hmdevice.UNREACH
except Exception as err: # noqa: BLE001
self._connected = False
_LOGGER.error("Exception while linking %s: %s", self._address, str(err))
@@ -119,7 +132,7 @@ class HMDevice(Entity):
# Availability has changed
if self.available != (not self._hmdevice.UNREACH):
self._attr_available = not self._hmdevice.UNREACH
self._available = not self._hmdevice.UNREACH
has_changed = True
# If it has changed data point, update Home Assistant
@@ -200,14 +213,14 @@ class HMHub(Entity):
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, homematic: HMConnection, name: str) -> None:
def __init__(self, hass, homematic, name):
"""Initialize HomeMatic hub."""
self.hass = hass
self.entity_id = f"{DOMAIN}.{name.lower()}"
self._homematic = homematic
self._variables: dict[str, Any] = {}
self._variables = {}
self._name = name
self._state: int | None = None
self._state = None
# Load data
track_time_interval(self.hass, self._update_hub, SCAN_INTERVAL_HUB)
@@ -217,12 +230,12 @@ class HMHub(Entity):
self.hass.add_job(self._update_variables, None)
@property
def name(self) -> str:
def name(self):
"""Return the name of the device."""
return self._name
@property
def state(self) -> int | None:
def state(self):
"""Return the state of the entity."""
return self._state
@@ -232,7 +245,7 @@ class HMHub(Entity):
return self._variables.copy()
@property
def icon(self) -> str:
def icon(self):
"""Return the icon to use in the frontend, if any."""
return "mdi:gradient-vertical"

View File

@@ -344,4 +344,4 @@ class HMSensor(HMDevice, SensorEntity):
if self._state:
self._data.update({self._state: None})
else:
_LOGGER.critical("Unable to initialize sensor: %s", self.name)
_LOGGER.critical("Unable to initialize sensor: %s", self._name)

View File

@@ -329,14 +329,14 @@ class IDriveE2BackupAgent(BackupAgent):
return self._backup_cache
backups = {}
paginator = self._client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=self._bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)
response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket)
# Filter for metadata files only
metadata_files = [
obj
for obj in response.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
]
for metadata_file in metadata_files:
try:

View File

@@ -310,7 +310,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
return self._config[CONF_HAS_TIME]
@property
def icon(self) -> str | None:
def icon(self):
"""Return the icon to be used for this entity."""
return self._config.get(CONF_ICON)

View File

@@ -243,7 +243,7 @@ class InputNumber(collection.CollectionEntity, RestoreEntity):
return self._config.get(CONF_NAME)
@property
def icon(self) -> str | None:
def icon(self):
"""Return the icon to be used for this entity."""
return self._config.get(CONF_ICON)

View File

@@ -451,7 +451,7 @@ class AirPlayDevice(MediaPlayerEntity):
return self.device_name
@property
def icon(self) -> str:
def icon(self):
"""Return the icon to use in the frontend, if any."""
if self.selected is True:
return "mdi:volume-high"

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2025.1.0"]
"requirements": ["pylitterbot==2025.0.0"]
}

View File

@@ -107,20 +107,36 @@ class APIData:
class AirSensor(SensorEntity):
"""Single authority air sensor."""
_attr_icon = "mdi:cloud-outline"
ICON = "mdi:cloud-outline"
def __init__(self, name, api_data):
"""Initialize the sensor."""
self._attr_name = self._key = name
self._name = name
self._api_data = api_data
self._site_data = None
self._state = None
self._updated = None
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def site_data(self):
"""Return the dict of sites data."""
return self._site_data
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self.ICON
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return other details about the sensor state."""
@@ -135,7 +151,7 @@ class AirSensor(SensorEntity):
sites_status: list = []
self._api_data.update()
if self._api_data.data:
self._site_data = self._api_data.data[self._key]
self._site_data = self._api_data.data[self._name]
self._updated = self._site_data[0]["updated"]
sites_status.extend(
site["pollutants_status"]
@@ -144,9 +160,9 @@ class AirSensor(SensorEntity):
)
if sites_status:
self._attr_native_value = max(set(sites_status), key=sites_status.count)
self._state = max(set(sites_status), key=sites_status.count)
else:
self._attr_native_value = None
self._state = None
def parse_species(species_data):

View File

@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/matter",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["matter-python-client==0.4.1"],
"requirements": ["python-matter-server==8.1.2"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
}

View File

@@ -1,14 +1,14 @@
{
"config": {
"abort": {
"addon_get_discovery_info_failed": "Failed to get Matter Server app discovery info.",
"addon_info_failed": "Failed to get Matter Server app info.",
"addon_install_failed": "Failed to install the Matter Server app.",
"addon_start_failed": "Failed to start the Matter Server app.",
"addon_get_discovery_info_failed": "Failed to get Matter Server add-on discovery info.",
"addon_info_failed": "Failed to get Matter Server add-on info.",
"addon_install_failed": "Failed to install the Matter Server add-on.",
"addon_start_failed": "Failed to start the Matter Server add-on.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"not_matter_addon": "Discovered app is not the official Matter Server app.",
"not_matter_addon": "Discovered add-on is not the official Matter Server add-on.",
"reconfiguration_successful": "Successfully reconfigured the Matter integration."
},
"error": {
@@ -18,15 +18,15 @@
},
"flow_title": "{name}",
"progress": {
"install_addon": "Please wait while the Matter Server app installation finishes. This can take several minutes.",
"start_addon": "Please wait while the Matter Server app starts. This app is what powers Matter in Home Assistant. This may take some seconds."
"install_addon": "Please wait while the Matter Server add-on installation finishes. This can take several minutes.",
"start_addon": "Please wait while the Matter Server add-on starts. This add-on is what powers Matter in Home Assistant. This may take some seconds."
},
"step": {
"hassio_confirm": {
"title": "Set up the Matter integration with the Matter Server app"
"title": "Set up the Matter integration with the Matter Server add-on"
},
"install_addon": {
"title": "The app installation has started"
"title": "The add-on installation has started"
},
"manual": {
"data": {
@@ -35,13 +35,13 @@
},
"on_supervisor": {
"data": {
"use_addon": "Use the official Matter Server Supervisor app"
"use_addon": "Use the official Matter Server Supervisor add-on"
},
"description": "Do you want to use the official Matter Server Supervisor app?\n\nIf you are already running the Matter Server in another app, in a custom container, natively etc., then do not select this option.",
"description": "Do you want to use the official Matter Server Supervisor add-on?\n\nIf you are already running the Matter Server in another add-on, in a custom container, natively etc., then do not select this option.",
"title": "Select connection method"
},
"start_addon": {
"title": "Starting app."
"title": "Starting add-on."
}
}
},

View File

@@ -10,6 +10,7 @@ from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
from homeassistant.components.vacuum import (
Segment,
StateVacuumEntity,
StateVacuumEntityDescription,
VacuumActivity,
@@ -70,6 +71,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Representation of a Matter Vacuum cleaner entity."""
_last_accepted_commands: list[int] | None = None
_last_service_area_feature_map: int | None = None
_supported_run_modes: (
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
) = None
@@ -136,6 +138,16 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"No supported run mode found to start the vacuum cleaner."
)
# Reset selected areas to an unconstrained selection to ensure start
# performs a full clean and does not reuse a previous area-targeted
# selection.
if VacuumEntityFeature.CLEAN_AREA in self.supported_features:
# Matter ServiceArea: an empty NewAreas list means unconstrained
# operation (full clean).
await self.send_device_command(
clusters.ServiceArea.Commands.SelectAreas(newAreas=[])
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
@@ -144,6 +156,66 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Pause the cleaning task."""
await self.send_device_command(clusters.RvcOperationalState.Commands.Pause())
@property
def _current_segments(self) -> list[Segment]:
"""Return the current cleanable segments reported by the device."""
supported_areas: list[clusters.ServiceArea.Structs.AreaStruct] = (
self.get_matter_attribute_value(
clusters.ServiceArea.Attributes.SupportedAreas
)
)
segments: list[Segment] = []
for area in supported_areas:
area_name = None
if area.areaInfo and area.areaInfo.locationInfo:
area_name = area.areaInfo.locationInfo.locationName
if area_name:
segments.append(
Segment(
id=str(area.areaID),
name=area_name,
)
)
return segments
async def async_get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned.
Returns a list of segments containing their ids and names.
"""
return self._current_segments
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Clean the specified segments.
Args:
segment_ids: List of segment IDs to clean.
**kwargs: Additional arguments (unused).
"""
# Convert string IDs to integers
area_ids = [int(segment_id) for segment_id in segment_ids]
# Ensure a CLEANING run mode is available before changing device state
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
if mode is None:
raise HomeAssistantError(
"No supported run mode found to start the vacuum cleaner."
)
# Send the SelectAreas command to the vacuum
await self.send_device_command(
clusters.ServiceArea.Commands.SelectAreas(newAreas=area_ids)
)
# Start cleaning using ChangeToMode with CLEANING tag
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
@@ -176,16 +248,34 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
state = VacuumActivity.CLEANING
self._attr_activity = state
if (
VacuumEntityFeature.CLEAN_AREA in self.supported_features
and self.registry_entry is not None
and (last_seen_segments := self.last_seen_segments) is not None
and self._current_segments != last_seen_segments
):
self.async_create_segments_issue()
@callback
def _calculate_features(self) -> None:
"""Calculate features for HA Vacuum platform."""
accepted_operational_commands: list[int] = self.get_matter_attribute_value(
clusters.RvcOperationalState.Attributes.AcceptedCommandList
)
# in principle the feature set should not change, except for the accepted commands
if self._last_accepted_commands == accepted_operational_commands:
service_area_feature_map: int | None = self.get_matter_attribute_value(
clusters.ServiceArea.Attributes.FeatureMap
)
# In principle the feature set should not change, except for accepted
# commands and service area feature map.
if (
self._last_accepted_commands == accepted_operational_commands
and self._last_service_area_feature_map == service_area_feature_map
):
return
self._last_accepted_commands = accepted_operational_commands
self._last_service_area_feature_map = service_area_feature_map
supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
supported_features |= VacuumEntityFeature.START
supported_features |= VacuumEntityFeature.STATE
@@ -212,6 +302,12 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.RETURN_HOME
# Check if Map feature is enabled for clean area support
if (
service_area_feature_map is not None
and service_area_feature_map & clusters.ServiceArea.Bitmaps.Feature.kMaps
):
supported_features |= VacuumEntityFeature.CLEAN_AREA
self._attr_supported_features = supported_features
@@ -228,6 +324,10 @@ DISCOVERY_SCHEMAS = [
clusters.RvcRunMode.Attributes.CurrentMode,
clusters.RvcOperationalState.Attributes.OperationalState,
),
optional_attributes=(
clusters.ServiceArea.Attributes.FeatureMap,
clusters.ServiceArea.Attributes.SupportedAreas,
),
device_type=(device_types.RoboticVacuumCleaner,),
allow_none_value=True,
),

View File

@@ -113,15 +113,35 @@ class NetdataSensor(SensorEntity):
def __init__(self, netdata, name, sensor, sensor_name, element, icon, unit, invert):
"""Initialize the Netdata sensor."""
self.netdata = netdata
self._state = None
self._sensor = sensor
self._element = element
if sensor_name is None:
sensor_name = self._sensor
self._attr_name = f"{name} {sensor_name}"
self._attr_icon = icon
self._attr_native_unit_of_measurement = unit
self._sensor_name = self._sensor if sensor_name is None else sensor_name
self._name = name
self._icon = icon
self._unit_of_measurement = unit
self._invert = invert
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._name} {self._sensor_name}"
@property
def native_unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return self._unit_of_measurement
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
return self._icon
@property
def native_value(self):
"""Return the state of the resources."""
return self._state
@property
def available(self) -> bool:
"""Could the resource be accessed during the last update call."""
@@ -131,9 +151,9 @@ class NetdataSensor(SensorEntity):
"""Get the latest data from Netdata REST API."""
await self.netdata.async_update()
resource_data = self.netdata.api.metrics.get(self._sensor)
self._attr_native_value = round(
resource_data["dimensions"][self._element]["value"], 2
) * (-1 if self._invert else 1)
self._state = round(resource_data["dimensions"][self._element]["value"], 2) * (
-1 if self._invert else 1
)
class NetdataAlarms(SensorEntity):
@@ -142,18 +162,29 @@ class NetdataAlarms(SensorEntity):
def __init__(self, netdata, name, host, port):
"""Initialize the Netdata alarm sensor."""
self.netdata = netdata
self._attr_name = f"{name} Alarms"
self._state = None
self._name = name
self._host = host
self._port = port
@property
def icon(self) -> str:
def name(self):
"""Return the name of the sensor."""
return f"{self._name} Alarms"
@property
def native_value(self):
"""Return the state of the resources."""
return self._state
@property
def icon(self):
"""Status symbol if type is symbol."""
if self._attr_native_value == "ok":
if self._state == "ok":
return "mdi:check"
if self._attr_native_value == "warning":
if self._state == "warning":
return "mdi:alert-outline"
if self._attr_native_value == "critical":
if self._state == "critical":
return "mdi:alert"
return "mdi:crosshairs-question"
@@ -166,7 +197,7 @@ class NetdataAlarms(SensorEntity):
"""Get the latest alarms from Netdata REST API."""
await self.netdata.async_update()
alarms = self.netdata.api.alarms["alarms"]
self._attr_native_value = None
self._state = None
number_of_alarms = len(alarms)
number_of_relevant_alarms = number_of_alarms
@@ -180,9 +211,9 @@ class NetdataAlarms(SensorEntity):
):
number_of_relevant_alarms = number_of_relevant_alarms - 1
elif alarms[alarm]["status"] == "CRITICAL":
self._attr_native_value = "critical"
self._state = "critical"
return
self._attr_native_value = "ok" if number_of_relevant_alarms == 0 else "warning"
self._state = "ok" if number_of_relevant_alarms == 0 else "warning"
class NetdataData:

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/ntfy",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["aiontfy"],
"loggers": ["aionfty"],
"quality_scale": "platinum",
"requirements": ["aiontfy==0.8.0"]
"requirements": ["aiontfy==0.7.0"]
}

View File

@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
@@ -62,7 +61,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
value_fn=(
lambda psn: psn.trophy_summary.trophy_level if psn.trophy_summary else None
),
state_class=SensorStateClass.MEASUREMENT,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.TROPHY_LEVEL_PROGRESS,
@@ -71,7 +69,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
lambda psn: psn.trophy_summary.progress if psn.trophy_summary else None
),
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.EARNED_TROPHIES_PLATINUM,
@@ -83,7 +80,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
else None
)
),
state_class=SensorStateClass.MEASUREMENT,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.EARNED_TROPHIES_GOLD,
@@ -93,7 +89,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
psn.trophy_summary.earned_trophies.gold if psn.trophy_summary else None
)
),
state_class=SensorStateClass.MEASUREMENT,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.EARNED_TROPHIES_SILVER,
@@ -105,7 +100,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
else None
)
),
state_class=SensorStateClass.MEASUREMENT,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.EARNED_TROPHIES_BRONZE,
@@ -117,7 +111,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
else None
)
),
state_class=SensorStateClass.MEASUREMENT,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_ID,

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.27"]
"requirements": ["pyportainer==1.0.23"]
}

View File

@@ -99,12 +99,8 @@ def setup_platform(
class RedditSensor(SensorEntity):
"""Representation of a Reddit sensor."""
_attr_icon = "mdi:reddit"
def __init__(self, reddit, subreddit: str, limit: int, sort_by: str) -> None:
"""Initialize the Reddit sensor."""
self._attr_name = f"reddit_{subreddit}"
self._attr_native_value = 0
self._reddit = reddit
self._subreddit = subreddit
self._limit = limit
@@ -112,6 +108,16 @@ class RedditSensor(SensorEntity):
self._subreddit_data: list = []
@property
def name(self):
"""Return the name of the sensor."""
return f"reddit_{self._subreddit}"
@property
def native_value(self):
"""Return the state of the sensor."""
return len(self._subreddit_data)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
@@ -121,6 +127,11 @@ class RedditSensor(SensorEntity):
CONF_SORT_BY: self._sort_by,
}
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:reddit"
def update(self) -> None:
"""Update data from Reddit API."""
self._subreddit_data = []
@@ -145,5 +156,3 @@ class RedditSensor(SensorEntity):
except praw.exceptions.PRAWException as err:
_LOGGER.error("Reddit error %s", err)
self._attr_native_value = len(self._subreddit_data)

View File

@@ -122,7 +122,6 @@ class RMVDepartureSensor(SensorEntity):
"""Implementation of an RMV departure sensor."""
_attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
def __init__(
self,
@@ -138,7 +137,7 @@ class RMVDepartureSensor(SensorEntity):
):
"""Initialize the sensor."""
self._station = station
self._attr_name = name
self._name = name
self._state = None
self.data = RMVDepartureData(
station,
@@ -150,7 +149,12 @@ class RMVDepartureSensor(SensorEntity):
max_journeys,
timeout,
)
self._attr_icon = ICONS[None]
self._icon = ICONS[None]
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def available(self) -> bool:
@@ -177,22 +181,32 @@ class RMVDepartureSensor(SensorEntity):
except IndexError:
return {}
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
@property
def native_unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return UnitOfTime.MINUTES
async def async_update(self) -> None:
"""Get the latest data and update the state."""
await self.data.async_update()
if self._attr_name == DEFAULT_NAME:
self._attr_name = self.data.station
if self._name == DEFAULT_NAME:
self._name = self.data.station
self._station = self.data.station
if not self.data.departures:
self._state = None
self._attr_icon = ICONS[None]
self._icon = ICONS[None]
return
self._state = self.data.departures[0].get("minutes")
self._attr_icon = ICONS[self.data.departures[0].get("product")]
self._icon = ICONS[self.data.departures[0].get("product")]
class RMVDepartureData:

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Lash-L"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/snoo",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["snoo"],
"quality_scale": "bronze",

View File

@@ -13,7 +13,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/snooz",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pysnooz==0.8.6"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@squishykid", "@Darsstar"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/solax",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["solax"],
"requirements": ["solax==3.2.3"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@ratsept"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/soma",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["api"],
"requirements": ["pysoma==0.0.12"]

View File

@@ -10,7 +10,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/somfy_mylink",
"integration_type": "hub",
"iot_class": "assumed_state",
"loggers": ["somfy_mylink_synergy"],
"requirements": ["somfy-mylink-synergy==1.0.6"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@ctalkington"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonarr",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["aiopyarr"],
"requirements": ["aiopyarr==23.4.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@rytilahti", "@shenxn"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/songpal",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["songpal"],
"requirements": ["python-songpal==0.16.2"],

View File

@@ -4,7 +4,6 @@
"codeowners": ["@kroimon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/soundtouch",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["libsoundtouch"],
"requirements": ["libsoundtouch==0.8"],

View File

@@ -179,42 +179,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
except ClientConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
"host": host,
"port": str(port),
"error": str(err),
},
f"Connection error connecting to Splunk at {host}:{port}: {err}"
) from err
except TimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"host": host, "port": str(port)},
f"Timeout connecting to Splunk at {host}:{port}"
) from err
except Exception as err:
_LOGGER.exception("Unexpected error setting up Splunk")
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="unexpected_error",
translation_placeholders={
"host": host,
"port": str(port),
"error": str(err),
},
f"Unexpected error connecting to Splunk: {err}"
) from err
if not connectivity_ok:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"host": host, "port": str(port)},
f"Unable to connect to Splunk instance at {host}:{port}"
)
if not token_ok:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
)
raise ConfigEntryAuthFailed("Invalid Splunk token - please reauthenticate")
# Send startup event
payload: dict[str, Any] = {

View File

@@ -85,40 +85,6 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN):
data=import_config,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the Splunk integration."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await self._async_validate_input(user_input)
if not errors:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=user_input,
title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_SSL, default=False): bool,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Optional(CONF_NAME): str,
}
),
self._get_reconfigure_entry().data,
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/splunk",
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["hass_splunk"],
"quality_scale": "legacy",

View File

@@ -107,12 +107,19 @@ rules:
status: exempt
comment: |
Integration does not create entities.
exception-translations: done
exception-translations:
status: todo
comment: |
Consider adding exception translations for user-facing errors beyond the current strings.json error section to provide more detailed translated error messages.
icon-translations:
status: exempt
comment: |
Integration does not create entities.
reconfiguration-flow: done
reconfiguration-flow:
status: todo
comment: |
Consider adding reconfiguration flow to allow users to update host, port, entity filter, and SSL settings without deleting and re-adding the config entry.
# Platinum
async-dependency:
status: todo

View File

@@ -6,7 +6,6 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_config": "The YAML configuration is invalid and cannot be imported. Please check your configuration.yaml file.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
@@ -27,25 +26,6 @@
"description": "The Splunk token is no longer valid. Please enter a new HTTP Event Collector token.",
"title": "Reauthenticate Splunk"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "[%key:common::config_flow::data::name%]",
"port": "[%key:common::config_flow::data::port%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"token": "HTTP Event Collector token",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"host": "[%key:component::splunk::config::step::user::data_description::host%]",
"name": "[%key:component::splunk::config::step::user::data_description::name%]",
"port": "[%key:component::splunk::config::step::user::data_description::port%]",
"ssl": "[%key:component::splunk::config::step::user::data_description::ssl%]",
"token": "[%key:component::splunk::config::step::user::data_description::token%]",
"verify_ssl": "[%key:component::splunk::config::step::user::data_description::verify_ssl%]"
},
"description": "Update your Splunk HTTP Event Collector connection settings."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -68,23 +48,6 @@
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Unable to connect to Splunk at {host}:{port}."
},
"connection_error": {
"message": "Unable to connect to Splunk at {host}:{port}: {error}."
},
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"timeout_connect": {
"message": "Connection to Splunk at {host}:{port} timed out."
},
"unexpected_error": {
"message": "Unexpected error while connecting to Splunk at {host}:{port}: {error}."
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release.\n\nWhile importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the connection settings from your `{domain}:` configuration and configure the integration via the UI.\n\nNote: Entity filtering via YAML (`filter:`) will continue to work.",

View File

@@ -4,7 +4,6 @@
"codeowners": ["@briglx"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/srp_energy",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["srpenergy"],
"requirements": ["srpenergy==1.3.6"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@anonym-tsk"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/starline",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["starline"],
"requirements": ["starline==0.1.5"]

View File

@@ -120,7 +120,7 @@ class StarlineSensor(StarlineEntity, SensorEntity):
self.entity_description = description
@property
def icon(self) -> str | None:
def icon(self):
"""Icon to use in the frontend, if any."""
if self._key == "battery":
return icon_for_battery_level(

View File

@@ -4,7 +4,6 @@
"codeowners": ["@boswelja"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/starlink",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["starlink-grpc-core==1.2.3"]
}

View File

@@ -14,7 +14,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/steamist",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aiosteamist", "discovery30303"],
"requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.3"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@fucm", "@ThyMYthOS"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/stiebel_eltron",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pystiebeleltron"],
"requirements": ["pystiebeleltron==0.2.5"]

View File

@@ -4,7 +4,6 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/streamlabswater",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["streamlabswater"],
"requirements": ["streamlabswater==1.0.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@G-Two"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/subaru",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["stdiomask", "subarulink"],
"requirements": ["subarulink==0.7.15"]

View File

@@ -5,7 +5,6 @@
"codeowners": ["@ooii", "@jb101010-2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/suez_water",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],
"quality_scale": "bronze",

View File

@@ -9,7 +9,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["PySrDaliGateway==0.19.3"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@benleb", "@danielhiversen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/surepetcare",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["rich", "surepy"],
"requirements": ["surepy==0.9.0"]

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from swisshydrodata import SwissHydroData
import voluptuous as vol
@@ -67,8 +67,8 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Swiss hydrological sensor."""
station: int = config[CONF_STATION]
monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS]
station = config[CONF_STATION]
monitored_conditions = config[CONF_MONITORED_CONDITIONS]
hydro_data = HydrologicalData(station)
hydro_data.update()
@@ -93,24 +93,38 @@ class SwissHydrologicalDataSensor(SensorEntity):
"Data provided by the Swiss Federal Office for the Environment FOEN"
)
def __init__(
self, hydro_data: HydrologicalData, station: int, condition: str
) -> None:
def __init__(self, hydro_data, station, condition):
"""Initialize the Swiss hydrological sensor."""
self.hydro_data = hydro_data
data = hydro_data.data
if TYPE_CHECKING:
# Setup will fail in setup_platform if the data is None.
assert data is not None
self._condition = condition
self._data: dict[str, Any] | None = data
self._attr_icon = CONDITIONS[condition]
self._attr_name = f"{data['water-body-name']} {condition}"
self._attr_native_unit_of_measurement = data["parameters"][condition]["unit"]
self._attr_unique_id = f"{station}_{condition}"
self._data = self._state = self._unit_of_measurement = None
self._icon = CONDITIONS[condition]
self._station = station
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._data['water-body-name']} {self._condition}"
@property
def unique_id(self) -> str:
"""Return a unique, friendly identifier for this entity."""
return f"{self._station}_{self._condition}"
@property
def native_unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
if self._state is not None:
return self.hydro_data.data["parameters"][self._condition]["unit"]
return None
@property
def native_value(self):
"""Return the state of the sensor."""
if isinstance(self._state, (int, float)):
return round(self._state, 2)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
@@ -132,28 +146,32 @@ class SwissHydrologicalDataSensor(SensorEntity):
return attrs
@property
def icon(self):
"""Icon to use in the frontend."""
return self._icon
def update(self) -> None:
"""Get the latest data and update the state."""
self.hydro_data.update()
self._data = self.hydro_data.data
self._attr_native_value = None
if self._data is not None:
state = self._data["parameters"][self._condition]["value"]
if isinstance(state, (int, float)):
self._attr_native_value = round(state, 2)
if self._data is None:
self._state = None
else:
self._state = self._data["parameters"][self._condition]["value"]
class HydrologicalData:
"""The Class for handling the data retrieval."""
def __init__(self, station: int) -> None:
def __init__(self, station):
"""Initialize the data object."""
self.station = station
self.data: dict[str, Any] | None = None
self.data = None
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self) -> None:
def update(self):
"""Get the latest data."""
shd = SwissHydroData()

View File

@@ -4,7 +4,6 @@
"codeowners": ["@fabaff", "@miaucl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/swiss_public_transport",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["opendata_transport"],
"requirements": ["python-opendata-transport==0.5.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@jafar-atili"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/switchbee",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["pyswitchbee==1.8.3"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@thecode", "@YogevBokobza"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/switcher_kis",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aioswitcher"],
"quality_scale": "silver",

View File

@@ -4,7 +4,6 @@
"codeowners": ["@zhulik"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/syncthing",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["aiosyncthing"],
"requirements": ["aiosyncthing==0.7.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@nielstron"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/syncthru",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysyncthru"],
"requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.1"],

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Guy293"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tami4",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["Tami4EdgeAPI==3.0"]
}

View File

@@ -468,7 +468,7 @@ async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse:
targets = _build_targets(service)
service_responses: JsonValueType = []
errors: list[tuple[Exception, str]] = []
errors: list[tuple[HomeAssistantError, str]] = []
# invoke the service for each target
for target_config_entry, target_chat_id, target_notify_entity_id in targets:
@@ -495,7 +495,7 @@ async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse:
assert isinstance(service_responses, list)
service_responses.extend(formatted_responses)
except (HomeAssistantError, TelegramError) as ex:
except HomeAssistantError as ex:
target = target_notify_entity_id or str(target_chat_id)
errors.append((ex, target))

View File

@@ -433,6 +433,7 @@ class TelegramNotificationService:
async def _send_msgs(
self,
func_send: Callable,
msg_error: str,
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
@@ -458,10 +459,12 @@ class TelegramNotificationService:
response: Message = await self._send_msg(
func_send,
msg_error,
message_tag,
chat_id,
*args_msg,
context=context,
suppress_error=len(chat_ids) > 1,
**kwargs_msg,
)
if response:
@@ -472,39 +475,58 @@ class TelegramNotificationService:
async def _send_msg(
self,
func_send: Callable,
msg_error: str,
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
suppress_error: bool = False,
**kwargs_msg: Any,
) -> Any:
"""Send one message."""
out = await func_send(*args_msg, **kwargs_msg)
if isinstance(out, Message):
chat_id = out.chat_id
message_id = out.message_id
self._last_message_id[chat_id] = message_id
_LOGGER.debug(
"Last message ID: %s (from chat_id %s)",
self._last_message_id,
chat_id,
)
event_data: dict[str, Any] = {
ATTR_CHAT_ID: chat_id,
ATTR_MESSAGEID: message_id,
}
if message_tag is not None:
event_data[ATTR_MESSAGE_TAG] = message_tag
if kwargs_msg.get(ATTR_MESSAGE_THREAD_ID) is not None:
event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[ATTR_MESSAGE_THREAD_ID]
event_data["bot"] = _get_bot_info(self.bot, self.config)
self.hass.bus.async_fire(EVENT_TELEGRAM_SENT, event_data, context=context)
async_dispatcher_send(
self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data
try:
out = await func_send(*args_msg, **kwargs_msg)
if isinstance(out, Message):
chat_id = out.chat_id
message_id = out.message_id
self._last_message_id[chat_id] = message_id
_LOGGER.debug(
"Last message ID: %s (from chat_id %s)",
self._last_message_id,
chat_id,
)
event_data: dict[str, Any] = {
ATTR_CHAT_ID: chat_id,
ATTR_MESSAGEID: message_id,
}
if message_tag is not None:
event_data[ATTR_MESSAGE_TAG] = message_tag
if kwargs_msg.get(ATTR_MESSAGE_THREAD_ID) is not None:
event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[
ATTR_MESSAGE_THREAD_ID
]
event_data["bot"] = _get_bot_info(self.bot, self.config)
self.hass.bus.async_fire(
EVENT_TELEGRAM_SENT, event_data, context=context
)
async_dispatcher_send(
self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data
)
except TelegramError as exc:
if not suppress_error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_failed",
translation_placeholders={"error": str(exc)},
) from exc
_LOGGER.error(
"%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg
)
return None
return out
async def send_message(
@@ -520,6 +542,7 @@ class TelegramNotificationService:
params = self._get_msg_kwargs(kwargs)
return await self._send_msgs(
self.bot.send_message,
"Error sending message",
params[ATTR_MESSAGE_TAG],
text,
chat_id=chat_id,
@@ -544,6 +567,7 @@ class TelegramNotificationService:
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
deleted: bool = await self._send_msg(
self.bot.delete_message,
"Error deleting message",
None,
chat_id,
message_id,
@@ -620,6 +644,7 @@ class TelegramNotificationService:
return await self._send_msg(
self.bot.edit_message_media,
"Error editing message media",
params[ATTR_MESSAGE_TAG],
media=media,
chat_id=chat_id,
@@ -653,6 +678,7 @@ class TelegramNotificationService:
_LOGGER.debug("Editing message with ID %s", message_id or inline_message_id)
return await self._send_msg(
self.bot.edit_message_text,
"Error editing text message",
params[ATTR_MESSAGE_TAG],
text,
chat_id=chat_id,
@@ -667,6 +693,7 @@ class TelegramNotificationService:
if type_edit == SERVICE_EDIT_CAPTION:
return await self._send_msg(
self.bot.edit_message_caption,
"Error editing message attributes",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
message_id=message_id,
@@ -680,6 +707,7 @@ class TelegramNotificationService:
return await self._send_msg(
self.bot.edit_message_reply_markup,
"Error editing message attributes",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
message_id=message_id,
@@ -707,6 +735,7 @@ class TelegramNotificationService:
)
await self._send_msg(
self.bot.answer_callback_query,
"Error sending answer callback query",
params[ATTR_MESSAGE_TAG],
callback_query_id,
text=message,
@@ -727,6 +756,7 @@ class TelegramNotificationService:
_LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id)
is_successful = await self._send_msg(
self.bot.send_chat_action,
"Error sending action",
None,
chat_id=chat_id,
action=chat_action,
@@ -761,6 +791,7 @@ class TelegramNotificationService:
if file_type == SERVICE_SEND_PHOTO:
return await self._send_msgs(
self.bot.send_photo,
"Error sending photo",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
photo=file_content,
@@ -777,6 +808,7 @@ class TelegramNotificationService:
if file_type == SERVICE_SEND_STICKER:
return await self._send_msgs(
self.bot.send_sticker,
"Error sending sticker",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
sticker=file_content,
@@ -791,6 +823,7 @@ class TelegramNotificationService:
if file_type == SERVICE_SEND_VIDEO:
return await self._send_msgs(
self.bot.send_video,
"Error sending video",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
video=file_content,
@@ -807,6 +840,7 @@ class TelegramNotificationService:
if file_type == SERVICE_SEND_DOCUMENT:
return await self._send_msgs(
self.bot.send_document,
"Error sending document",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
document=file_content,
@@ -823,6 +857,7 @@ class TelegramNotificationService:
if file_type == SERVICE_SEND_VOICE:
return await self._send_msgs(
self.bot.send_voice,
"Error sending voice",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
voice=file_content,
@@ -838,6 +873,7 @@ class TelegramNotificationService:
# SERVICE_SEND_ANIMATION
return await self._send_msgs(
self.bot.send_animation,
"Error sending animation",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
animation=file_content,
@@ -863,6 +899,7 @@ class TelegramNotificationService:
if stickerid:
return await self._send_msgs(
self.bot.send_sticker,
"Error sending sticker",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
sticker=stickerid,
@@ -888,6 +925,7 @@ class TelegramNotificationService:
params = self._get_msg_kwargs(kwargs)
return await self._send_msgs(
self.bot.send_location,
"Error sending location",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
latitude=latitude,
@@ -913,6 +951,7 @@ class TelegramNotificationService:
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
return await self._send_msgs(
self.bot.send_poll,
"Error sending poll",
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
question=question,
@@ -935,7 +974,9 @@ class TelegramNotificationService:
) -> Any:
"""Remove bot from chat."""
_LOGGER.debug("Leave from chat ID %s", chat_id)
return await self._send_msg(self.bot.leave_chat, None, chat_id, context=context)
return await self._send_msg(
self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context
)
async def set_message_reaction(
self,
@@ -959,6 +1000,7 @@ class TelegramNotificationService:
await self._send_msg(
self.bot.set_message_reaction,
"Error setting message reaction",
params[ATTR_MESSAGE_TAG],
chat_id,
message_id,
@@ -981,6 +1023,7 @@ class TelegramNotificationService:
directory_path = self.hass.config.path(DOMAIN)
file: File = await self._send_msg(
self.bot.get_file,
"Error getting file",
None,
file_id=file_id,
context=context,

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["telegram"],
"quality_scale": "silver",

View File

@@ -4,7 +4,6 @@
"codeowners": ["@fredrike"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tellduslive",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["tellduslive==0.10.12"]
}

View File

@@ -18,7 +18,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["tesla_wall_connector"],
"requirements": ["tesla-wall-connector==1.1.0"]

View File

@@ -53,7 +53,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/thermobeacon",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["thermobeacon-ble==0.10.0"]
}

View File

@@ -23,7 +23,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/thermopro",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["thermopro-ble==1.1.3"]
}

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.35.0"]

View File

@@ -11,7 +11,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/tilt_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["tilt-ble==1.0.1"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@boralyl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/todoist",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["todoist"],
"requirements": ["todoist-api-python==3.1.0"]

View File

@@ -12,7 +12,6 @@
"config_flow": true,
"dependencies": ["bluetooth"],
"documentation": "https://www.home-assistant.io/integrations/togrill",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["togrill_bluetooth"],
"quality_scale": "bronze",

View File

@@ -9,7 +9,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/tolo",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["tololib"],
"requirements": ["tololib==1.2.2"]

View File

@@ -12,7 +12,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/toon",
"integration_type": "device",
"iot_class": "cloud_push",
"loggers": ["toonapi"],
"requirements": ["toonapi==0.3.0"]

View File

@@ -131,15 +131,34 @@ class TorqueReceiveDataView(HomeAssistantView):
class TorqueSensor(SensorEntity):
"""Representation of a Torque sensor."""
_attr_icon = "mdi:car"
def __init__(self, name, unit):
"""Initialize the sensor."""
self._attr_name = name
self._attr_native_unit_of_measurement = unit
self._name = name
self._unit = unit
self._state = None
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def icon(self):
"""Return the default icon of the sensor."""
return "mdi:car"
@callback
def async_on_update(self, value):
"""Receive an update."""
self._attr_native_value = value
self._state = value
self.async_write_ha_state()

View File

@@ -4,7 +4,6 @@
"codeowners": ["@austinmroczek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["total_connect_client"],
"requirements": ["total-connect-client==2025.12.2"]

View File

@@ -7,7 +7,6 @@
"homekit": {
"models": ["TRADFRI"]
},
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pytradfri"],
"requirements": ["pytradfri[async]==9.0.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gjohansson-ST"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/trafikverket_camera",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
"requirements": ["pytrafikverket==1.1.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gjohansson-ST"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
"requirements": ["pytrafikverket==1.1.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gjohansson-ST"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/trafikverket_train",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
"requirements": ["pytrafikverket==1.1.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gjohansson-ST"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
"requirements": ["pytrafikverket==1.1.1"]

View File

@@ -78,16 +78,25 @@ class TransportNSWSensor(SensorEntity):
_attr_attribution = "Data provided by Transport NSW"
_attr_device_class = SensorDeviceClass.DURATION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, data, stop_id, name):
"""Initialize the sensor."""
self.data = data
self._attr_name = name
self._name = name
self._stop_id = stop_id
self._times = None
self._attr_icon = ICONS[None]
self._times = self._state = None
self._icon = ICONS[None]
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@@ -104,12 +113,22 @@ class TransportNSWSensor(SensorEntity):
}
return None
@property
def native_unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return UnitOfTime.MINUTES
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
def update(self) -> None:
"""Get the latest data from Transport NSW and update the states."""
self.data.update()
self._times = self.data.info
self._attr_native_value = self._times[ATTR_DUE_IN]
self._attr_icon = ICONS[self._times[ATTR_MODE]]
self._state = self._times[ATTR_DUE_IN]
self._icon = ICONS[self._times[ATTR_MODE]]
def _get_value(value):

View File

@@ -1,17 +1,13 @@
"""API for xbox bound to Home Assistant OAuth."""
from aiohttp import ClientError
from httpx import AsyncClient, HTTPStatusError, RequestError
from http import HTTPStatus
from aiohttp.client_exceptions import ClientResponseError
from httpx import AsyncClient
from pythonxbox.authentication.manager import AuthenticationManager
from pythonxbox.authentication.models import OAuth2TokenResponse
from pythonxbox.common.exceptions import AuthenticationException
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.util.dt import utc_from_timestamp
@@ -34,12 +30,16 @@ class AsyncConfigEntryAuth(AuthenticationManager):
if not self._oauth_session.valid_token:
try:
await self._oauth_session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_exception",
) from e
except (OAuth2TokenRequestTransientError, ClientError) as e:
except ClientResponseError as e:
if (
HTTPStatus.BAD_REQUEST
<= e.status
< HTTPStatus.INTERNAL_SERVER_ERROR
):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_exception",
) from e
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="request_exception",
@@ -47,18 +47,7 @@ class AsyncConfigEntryAuth(AuthenticationManager):
self.oauth = self._get_oauth_token()
# This will skip the OAuth refresh and only refresh User and XSTS tokens
try:
await super().refresh_tokens()
except AuthenticationException as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_exception",
) from e
except (RequestError, HTTPStatusError) as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
await super().refresh_tokens()
def _get_oauth_token(self) -> OAuth2TokenResponse:
tokens = {**self._oauth_session.token}

View File

@@ -160,7 +160,6 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
key=XboxSensor.GAMER_SCORE,
translation_key=XboxSensor.GAMER_SCORE,
value_fn=lambda x, _: x.gamer_score,
state_class=SensorStateClass.MEASUREMENT,
),
XboxSensorEntityDescription(
key=XboxSensor.ACCOUNT_TIER,
@@ -188,13 +187,11 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
key=XboxSensor.FOLLOWING,
translation_key=XboxSensor.FOLLOWING,
value_fn=lambda x, _: x.detail.following_count if x.detail else None,
state_class=SensorStateClass.MEASUREMENT,
),
XboxSensorEntityDescription(
key=XboxSensor.FOLLOWER,
translation_key=XboxSensor.FOLLOWER,
value_fn=lambda x, _: x.detail.follower_count if x.detail else None,
state_class=SensorStateClass.MEASUREMENT,
),
XboxSensorEntityDescription(
key=XboxSensor.NOW_PLAYING,
@@ -207,7 +204,6 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
key=XboxSensor.FRIENDS,
translation_key=XboxSensor.FRIENDS,
value_fn=lambda x, _: x.detail.friend_count if x.detail else None,
state_class=SensorStateClass.MEASUREMENT,
),
XboxSensorEntityDescription(
key=XboxSensor.IN_PARTY,

View File

@@ -158,7 +158,7 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity):
super().__init__(device, name, xiaomi_hub, config_entry)
@property
def icon(self) -> str:
def icon(self):
"""Return the icon to use in the frontend, if any."""
if self._data_key == "status":
return "mdi:power-plug"

View File

@@ -5948,7 +5948,7 @@
"name": "Samsung Smart TV"
},
"syncthru": {
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Samsung SyncThru Printer"
@@ -6411,7 +6411,7 @@
},
"snooz": {
"name": "Snooz",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
@@ -6440,7 +6440,7 @@
},
"solax": {
"name": "SolaX Power",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6463,7 +6463,7 @@
},
"sonarr": {
"name": "Sonarr",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6495,7 +6495,7 @@
"name": "Sony Projector"
},
"songpal": {
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"name": "Sony Songpal"
@@ -6510,7 +6510,7 @@
},
"soundtouch": {
"name": "Bose SoundTouch",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6534,7 +6534,7 @@
},
"splunk": {
"name": "Splunk",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"single_config_entry": true
@@ -6553,7 +6553,7 @@
},
"srp_energy": {
"name": "SRP Energy",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -6571,7 +6571,7 @@
},
"starlink": {
"name": "Starlink",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6595,13 +6595,13 @@
},
"steamist": {
"name": "Steamist",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
"stiebel_eltron": {
"name": "STIEBEL ELTRON",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6613,7 +6613,7 @@
},
"streamlabswater": {
"name": "StreamLabs",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -6625,7 +6625,7 @@
},
"suez_water": {
"name": "Suez Water",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -6678,7 +6678,7 @@
},
"swiss_public_transport": {
"name": "Swiss public transport",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -6729,7 +6729,7 @@
},
"syncthing": {
"name": "Syncthing",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6801,7 +6801,7 @@
},
"tami4": {
"name": "Tami4 Edge / Edge+",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -6869,7 +6869,7 @@
"name": "Telegram"
},
"telegram_bot": {
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push",
"name": "Telegram bot"
@@ -6921,7 +6921,7 @@
"name": "Tesla Powerwall"
},
"tesla_wall_connector": {
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Tesla Wall Connector"
@@ -6959,7 +6959,7 @@
},
"thermobeacon": {
"name": "ThermoBeacon",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
@@ -6970,7 +6970,7 @@
},
"thermopro": {
"name": "ThermoPro",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
@@ -7040,7 +7040,7 @@
"name": "Tilt",
"integrations": {
"tilt_ble": {
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"name": "Tilt Hydrometer BLE"
@@ -7066,19 +7066,19 @@
},
"todoist": {
"name": "Todoist",
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"togrill": {
"name": "ToGrill",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
"tolo": {
"name": "TOLO Sauna",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -7096,7 +7096,7 @@
},
"toon": {
"name": "Toon",
"integration_type": "device",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
@@ -7171,25 +7171,25 @@
"name": "Trafikverket",
"integrations": {
"trafikverket_camera": {
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Trafikverket Camera"
},
"trafikverket_ferry": {
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Trafikverket Ferry"
},
"trafikverket_train": {
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Trafikverket Train"
},
"trafikverket_weatherstation": {
"integration_type": "service",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Trafikverket Weather Station"

View File

@@ -711,7 +711,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [
TypeHintMatch(
function_name="icon",
return_type=["str", None],
mandatory=True,
),
TypeHintMatch(
function_name="entity_picture",

12
requirements_all.txt generated
View File

@@ -339,7 +339,7 @@ aionanoleaf2==1.0.2
aionotion==2024.03.0
# homeassistant.components.ntfy
aiontfy==0.8.0
aiontfy==0.7.0
# homeassistant.components.nut
aionut==4.3.4
@@ -1466,9 +1466,6 @@ lxml==6.0.1
# homeassistant.components.matrix
matrix-nio==0.25.2
# homeassistant.components.matter
matter-python-client==0.4.1
# homeassistant.components.maxcube
maxcube-api==0.4.3
@@ -2227,7 +2224,7 @@ pyliebherrhomeapi==0.3.0
pylitejet==0.6.3
# homeassistant.components.litterrobot
pylitterbot==2025.1.0
pylitterbot==2025.0.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.26.0
@@ -2367,7 +2364,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.27
pyportainer==1.0.23
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2583,6 +2580,9 @@ python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
python-linkplay==0.2.12
# homeassistant.components.matter
python-matter-server==8.1.2
# homeassistant.components.melcloud
python-melcloud==0.1.2

View File

@@ -324,7 +324,7 @@ aionanoleaf2==1.0.2
aionotion==2024.03.0
# homeassistant.components.ntfy
aiontfy==0.8.0
aiontfy==0.7.0
# homeassistant.components.nut
aionut==4.3.4
@@ -1282,9 +1282,6 @@ lxml==6.0.1
# homeassistant.components.matrix
matrix-nio==0.25.2
# homeassistant.components.matter
matter-python-client==0.4.1
# homeassistant.components.maxcube
maxcube-api==0.4.3
@@ -1898,7 +1895,7 @@ pyliebherrhomeapi==0.3.0
pylitejet==0.6.3
# homeassistant.components.litterrobot
pylitterbot==2025.1.0
pylitterbot==2025.0.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.26.0
@@ -2017,7 +2014,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.27
pyportainer==1.0.23
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2179,6 +2176,9 @@ python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
python-linkplay==0.2.12
# homeassistant.components.matter
python-matter-server==8.1.2
# homeassistant.components.melcloud
python-melcloud==0.1.2

View File

@@ -203,6 +203,11 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"sense": {"sense-energy": {"async-timeout"}},
"slimproto": {"aioslimproto": {"async-timeout"}},
"surepetcare": {"surepy": {"async-timeout"}},
"tami4": {
# https://github.com/SeleniumHQ/selenium/issues/16943
# tami4 > selenium > types*
"selenium": {"types-certifi", "types-urllib3"},
},
"travisci": {
# https://github.com/menegazzo/travispy seems to be unmaintained
# and unused https://www.home-assistant.io/integrations/travisci

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Generator
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -53,11 +53,9 @@ def mock_client(agent_backup: AgentBackup) -> Generator[AsyncMock]:
client = create_client.return_value
tar_file, metadata_file = suggested_filenames(agent_backup)
# Mock the paginator for list_objects_v2
client.get_paginator = MagicMock()
client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": [{"Key": tar_file}, {"Key": metadata_file}]}
]
client.list_objects_v2.return_value = {
"Contents": [{"Key": tar_file}, {"Key": metadata_file}]
}
client.create_multipart_upload.return_value = {"UploadId": "upload_id"}
client.upload_part.return_value = {"ETag": "etag"}
client.list_buckets.return_value = {

View File

@@ -179,9 +179,7 @@ async def test_agents_get_backup_does_not_throw_on_not_found(
mock_client: MagicMock,
) -> None:
"""Test agent get backup does not throw on a backup not found."""
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": []}
]
mock_client.list_objects_v2.return_value = {"Contents": []}
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"})
@@ -204,20 +202,18 @@ async def test_agents_list_backups_with_corrupted_metadata(
agent = IDriveE2BackupAgent(hass, mock_config_entry)
# Set up mock responses for both valid and corrupted metadata files
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{
"Contents": [
{
"Key": "valid_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{
"Key": "corrupted_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
]
}
]
mock_client.list_objects_v2.return_value = {
"Contents": [
{
"Key": "valid_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{
"Key": "corrupted_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
]
}
# Mock responses for get_object calls
valid_metadata = json.dumps(agent_backup.as_dict())
@@ -274,9 +270,7 @@ async def test_agents_delete_not_throwing_on_not_found(
mock_client: MagicMock,
) -> None:
"""Test agent delete backup does not throw on a backup not found."""
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": []}
]
mock_client.list_objects_v2.return_value = {"Contents": []}
client = await hass_ws_client(hass)
@@ -290,7 +284,7 @@ async def test_agents_delete_not_throwing_on_not_found(
assert response["success"]
assert response["result"] == {"agent_errors": {}}
assert mock_client.delete_objects.call_count == 0
assert mock_client.delete_object.call_count == 0
async def test_agents_upload(
@@ -496,27 +490,20 @@ async def test_cache_expiration(
metadata_content = json.dumps(agent_backup.as_dict())
mock_body = AsyncMock()
mock_body.read.return_value = metadata_content.encode()
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{
"Contents": [
{
"Key": "test.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
}
]
}
]
mock_client.get_object.return_value = {"Body": mock_body}
mock_client.list_objects_v2.return_value = {
"Contents": [
{"Key": "test.metadata.json", "LastModified": "2023-01-01T00:00:00+00:00"}
]
}
# First call should query IDrive e2
await agent.async_list_backups()
assert mock_client.get_paginator.call_count == 1
assert mock_client.list_objects_v2.call_count == 1
assert mock_client.get_object.call_count == 1
# Second call should use cache
await agent.async_list_backups()
assert mock_client.get_paginator.call_count == 1
assert mock_client.list_objects_v2.call_count == 1
assert mock_client.get_object.call_count == 1
# Set cache to expire
@@ -524,7 +511,7 @@ async def test_cache_expiration(
# Third call should query IDrive e2 again
await agent.async_list_backups()
assert mock_client.get_paginator.call_count == 2
assert mock_client.list_objects_v2.call_count == 2
assert mock_client.get_object.call_count == 2
@@ -539,88 +526,3 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
remove_listener()
assert DATA_BACKUP_AGENT_LISTENERS not in hass.data
async def test_list_backups_with_pagination(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test listing backups when paginating through multiple pages."""
# Create agent
agent = IDriveE2BackupAgent(hass, mock_config_entry)
# Create two different backups
backup1 = AgentBackup(
backup_id="backup1",
date="2023-01-01T00:00:00+00:00",
addons=[],
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=False,
homeassistant_version=None,
name="Backup 1",
protected=False,
size=0,
)
backup2 = AgentBackup(
backup_id="backup2",
date="2023-01-02T00:00:00+00:00",
addons=[],
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=False,
homeassistant_version=None,
name="Backup 2",
protected=False,
size=0,
)
# Setup two pages of results
page1 = {
"Contents": [
{
"Key": "backup1.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{"Key": "backup1.tar", "LastModified": "2023-01-01T00:00:00+00:00"},
]
}
page2 = {
"Contents": [
{
"Key": "backup2.metadata.json",
"LastModified": "2023-01-02T00:00:00+00:00",
},
{"Key": "backup2.tar", "LastModified": "2023-01-02T00:00:00+00:00"},
]
}
# Setup mock client
mock_client = mock_config_entry.runtime_data
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
page1,
page2,
]
# Mock get_object responses based on the key
async def mock_get_object(**kwargs):
"""Mock get_object with different responses based on the key."""
key = kwargs.get("Key", "")
if "backup1" in key:
mock_body = AsyncMock()
mock_body.read.return_value = json.dumps(backup1.as_dict()).encode()
return {"Body": mock_body}
# backup2
mock_body = AsyncMock()
mock_body.read.return_value = json.dumps(backup2.as_dict()).encode()
return {"Body": mock_body}
mock_client.get_object.side_effect = mock_get_object
# List backups and verify we got both
backups = await agent.async_list_backups()
assert len(backups) == 2
backup_ids = {backup.backup_id for backup in backups}
assert backup_ids == {"backup1", "backup2"}

View File

@@ -29,7 +29,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <VacuumEntityFeature: 12828>,
'supported_features': <VacuumEntityFeature: 29212>,
'translation_key': 'vacuum',
'unique_id': '00000000000004D2-000000000000002F-MatterNodeDevice-1-MatterVacuumCleaner-84-1',
'unit_of_measurement': None,
@@ -39,7 +39,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'ecodeebot',
'supported_features': <VacuumEntityFeature: 12828>,
'supported_features': <VacuumEntityFeature: 29212>,
}),
'context': <ANY>,
'entity_id': 'vacuum.ecodeebot',
@@ -79,7 +79,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <VacuumEntityFeature: 12828>,
'supported_features': <VacuumEntityFeature: 29212>,
'translation_key': 'vacuum',
'unique_id': '00000000000004D2-0000000000000028-MatterNodeDevice-1-MatterVacuumCleaner-84-1',
'unit_of_measurement': None,
@@ -89,7 +89,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '2BAVS-AB6031X-44PE',
'supported_features': <VacuumEntityFeature: 12828>,
'supported_features': <VacuumEntityFeature: 29212>,
}),
'context': <ANY>,
'entity_id': 'vacuum.2bavs_ab6031x_44pe',
@@ -129,7 +129,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <VacuumEntityFeature: 12316>,
'supported_features': <VacuumEntityFeature: 28700>,
'translation_key': 'vacuum',
'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1',
'unit_of_measurement': None,
@@ -139,7 +139,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Vacuum',
'supported_features': <VacuumEntityFeature: 12316>,
'supported_features': <VacuumEntityFeature: 28700>,
}),
'context': <ANY>,
'entity_id': 'vacuum.mock_vacuum',
@@ -179,7 +179,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <VacuumEntityFeature: 12316>,
'supported_features': <VacuumEntityFeature: 28700>,
'translation_key': 'vacuum',
'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-MatterVacuumCleaner-84-1',
'unit_of_measurement': None,
@@ -189,7 +189,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'K11+',
'supported_features': <VacuumEntityFeature: 12316>,
'supported_features': <VacuumEntityFeature: 28700>,
}),
'context': <ANY>,
'entity_id': 'vacuum.k11',

View File

@@ -1,12 +1,13 @@
"""Test Matter vacuum."""
from unittest.mock import MagicMock, call
from unittest.mock import MagicMock, call, patch
from chip.clusters import Objects as clusters
from matter_server.client.models.node import MatterNode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntityFeature
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -71,8 +72,13 @@ async def test_vacuum_actions(
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
assert matter_client.send_device_command.call_count == 2
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.ServiceArea.Commands.SelectAreas(newAreas=[]),
)
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1),
@@ -289,5 +295,100 @@ async def test_vacuum_actions_no_supported_run_modes(
blocking=True,
)
component = hass.data["vacuum"]
entity = component.get_entity(entity_id)
assert entity is not None
with pytest.raises(
HomeAssistantError,
match="No supported run mode found to start the vacuum cleaner",
):
await entity.async_clean_segments(["7"])
# Ensure no commands were sent to the device
assert matter_client.send_device_command.call_count == 0
@pytest.mark.parametrize("node_fixture", ["mock_vacuum_cleaner"])
async def test_vacuum_clean_area(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test vacuum clean_area action."""
# Fetch translations
await async_setup_component(hass, "homeassistant", {})
entity_id = "vacuum.mock_vacuum"
state = hass.states.get(entity_id)
assert state
# Verify CLEAN_AREA feature is supported
# Get the entity component to access entity
component = hass.data["vacuum"]
entity = component.get_entity(entity_id)
assert entity is not None
assert VacuumEntityFeature.CLEAN_AREA in entity.supported_features
# Get segments from the entity
segments = await entity.async_get_segments()
assert len(segments) == 3
assert segments[0].id == "7"
assert segments[0].name == "My Location A"
assert segments[1].id == "1234567"
assert segments[1].name == "My Location B"
assert segments[2].id == "2290649224"
assert segments[2].name == "My Location C"
# Test clean_segments method directly
await entity.async_clean_segments(["7", "1234567"])
# Verify both commands were sent: SelectAreas followed by ChangeToMode
assert matter_client.send_device_command.call_count == 2
# First call: SelectAreas with the area IDs
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.ServiceArea.Commands.SelectAreas(newAreas=[7, 1234567]),
)
# Second call: ChangeToMode to start cleaning (mode 1 is the CLEANING mode)
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1),
)
@pytest.mark.parametrize("node_fixture", ["mock_vacuum_cleaner"])
async def test_vacuum_create_segments_issue_when_segments_changed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test segments issue callback is triggered when segments changed."""
entity_id = "vacuum.mock_vacuum"
component = hass.data["vacuum"]
entity = component.get_entity(entity_id)
assert entity is not None
entity_registry.async_update_entity_options(
entity_id,
VACUUM_DOMAIN,
{
"last_seen_segments": [
{
"id": "7",
"name": "Old location A",
"group": None,
}
]
},
)
with patch.object(entity, "async_create_segments_issue") as mock_create_issue:
set_node_attribute(matter_node, 1, 97, 4, 0x02)
await trigger_subscription_callback(hass, matter_client)
assert mock_create_issue.call_count >= 1

View File

@@ -4,9 +4,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -41,7 +39,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PublicUniversalFriend Bronze trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -57,9 +54,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -94,7 +89,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PublicUniversalFriend Gold trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -160,9 +154,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -197,7 +189,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PublicUniversalFriend Next level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
@@ -374,9 +365,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -411,7 +400,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PublicUniversalFriend Platinum trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -427,9 +415,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -464,7 +450,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PublicUniversalFriend Silver trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -480,9 +465,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -517,7 +500,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PublicUniversalFriend Trophy level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.publicuniversalfriend_trophy_level',
@@ -532,9 +514,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -569,7 +549,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'testuser Bronze trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -585,9 +564,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -622,7 +599,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'testuser Gold trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -688,9 +664,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -725,7 +699,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'testuser Next level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
@@ -903,9 +876,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -940,7 +911,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'testuser Platinum trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -956,9 +926,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -993,7 +961,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'testuser Silver trophies',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'trophies',
}),
'context': <ANY>,
@@ -1009,9 +976,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -1046,7 +1011,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'testuser Trophy level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.testuser_trophy_level',

View File

@@ -224,94 +224,6 @@ async def test_import_flow_already_configured(
assert result["reason"] == "single_instance_allowed"
async def test_reconfigure_flow_success(
hass: HomeAssistant, mock_hass_splunk: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test successful reconfigure flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: "new-token-456",
CONF_HOST: "new-splunk.example.com",
CONF_PORT: 9088,
CONF_SSL: True,
CONF_VERIFY_SSL: False,
CONF_NAME: "Updated Splunk",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "new-splunk.example.com"
assert mock_config_entry.data[CONF_PORT] == 9088
assert mock_config_entry.data[CONF_TOKEN] == "new-token-456"
assert mock_config_entry.data[CONF_SSL] is True
assert mock_config_entry.data[CONF_VERIFY_SSL] is False
assert mock_config_entry.data[CONF_NAME] == "Updated Splunk"
assert mock_config_entry.title == "new-splunk.example.com:9088"
@pytest.mark.parametrize(
("side_effect", "error"),
[
([False, True], "cannot_connect"),
([True, False], "invalid_auth"),
(Exception("Unexpected error"), "unknown"),
],
)
async def test_reconfigure_flow_error_and_recovery(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: list[bool] | Exception,
error: str,
) -> None:
"""Test reconfigure flow errors and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_hass_splunk.check.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: "test-token-123",
CONF_HOST: "new-splunk.example.com",
CONF_PORT: 8088,
CONF_SSL: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": error}
# Test recovery
mock_hass_splunk.check.side_effect = None
mock_hass_splunk.check.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: "test-token-123",
CONF_HOST: "new-splunk.example.com",
CONF_PORT: 8088,
CONF_SSL: False,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reauth_flow_success(
hass: HomeAssistant, mock_hass_splunk: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:

View File

@@ -41,21 +41,12 @@ async def test_setup_entry_success(
@pytest.mark.parametrize(
("side_effect", "expected_state", "expected_error_key"),
("side_effect", "expected_state"),
[
([False, False], ConfigEntryState.SETUP_RETRY, "cannot_connect"),
(
ClientConnectionError("Connection failed"),
ConfigEntryState.SETUP_RETRY,
"connection_error",
),
(TimeoutError(), ConfigEntryState.SETUP_RETRY, "timeout_connect"),
(
Exception("Unexpected error"),
ConfigEntryState.SETUP_RETRY,
"unexpected_error",
),
([True, False], ConfigEntryState.SETUP_ERROR, "invalid_auth"),
([False, False], ConfigEntryState.SETUP_RETRY),
(ClientConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY),
(TimeoutError(), ConfigEntryState.SETUP_RETRY),
([True, False], ConfigEntryState.SETUP_ERROR),
],
)
async def test_setup_entry_error(
@@ -64,7 +55,6 @@ async def test_setup_entry_error(
mock_config_entry: MockConfigEntry,
side_effect: Exception | list[bool],
expected_state: ConfigEntryState,
expected_error_key: str,
) -> None:
"""Test setup with various errors results in appropriate states."""
mock_config_entry.add_to_hass(hass)
@@ -75,7 +65,6 @@ async def test_setup_entry_error(
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
assert mock_config_entry.error_reason_translation_key == expected_error_key
async def test_unload_entry(

View File

@@ -353,7 +353,7 @@ async def test_send_sticker_partial_error(
assert mock_send_sticker.call_count == 2
assert err.value.translation_key == "multiple_errors"
assert err.value.translation_placeholders == {
"errors": "`entity_id` notify.mock_title_mock_chat_1: mock network error\n`entity_id` notify.mock_title_mock_chat_2: mock network error"
"errors": "`entity_id` notify.mock_title_mock_chat_1: Action failed. mock network error\n`entity_id` notify.mock_title_mock_chat_2: Action failed. mock network error"
}
@@ -364,7 +364,7 @@ async def test_send_sticker_error(hass: HomeAssistant, webhook_bot) -> None:
) as mock_bot:
mock_bot.side_effect = NetworkError("mock network error")
with pytest.raises(TelegramError) as err:
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_SEND_STICKER,
@@ -377,8 +377,8 @@ async def test_send_sticker_error(hass: HomeAssistant, webhook_bot) -> None:
await hass.async_block_till_done()
mock_bot.assert_called_once()
assert err.typename == "NetworkError"
assert err.value.message == "mock network error"
assert err.value.translation_domain == DOMAIN
assert err.value.translation_key == "action_failed"
async def test_send_message_with_invalid_inline_keyboard(
@@ -2264,7 +2264,7 @@ async def test_download_file_when_bot_failed_to_get_file(
"homeassistant.components.telegram_bot.bot.Bot.get_file",
AsyncMock(side_effect=TelegramError("failed to get file")),
),
pytest.raises(TelegramError) as err,
pytest.raises(HomeAssistantError) as err,
):
await hass.services.async_call(
DOMAIN,
@@ -2273,9 +2273,7 @@ async def test_download_file_when_bot_failed_to_get_file(
blocking=True,
)
await hass.async_block_till_done()
assert err.typename == "TelegramError"
assert err.value.message == "failed to get file"
assert err.value.translation_key == "action_failed"
async def test_download_file_when_empty_file_path(

View File

@@ -104,30 +104,6 @@ def mock_authentication_manager() -> Generator[AsyncMock]:
yield client
@pytest.fixture(name="oauth2_session")
def mock_oauth2_session() -> Generator[AsyncMock]:
"""Mock OAuth2 session."""
with patch(
"homeassistant.components.xbox.OAuth2Session", autospec=True
) as mock_client:
client = mock_client.return_value
client.token = {
"access_token": "1234567890",
"expires_at": 1760697327.7298331,
"expires_in": 3600,
"refresh_token": "0987654321",
"scope": "XboxLive.signin XboxLive.offline_access",
"service": "xbox",
"token_type": "bearer",
"user_id": "AAAAAAAAAAAAAAAAAAAAA",
}
client.valid_token = False
yield client
@pytest.fixture(name="xbox_live_client")
def mock_xbox_live_client() -> Generator[AsyncMock]:
"""Mock xbox-webapi XboxLiveClient."""

View File

@@ -4,9 +4,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -41,7 +39,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'erics273 Follower',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -57,9 +54,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -94,7 +89,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'erics273 Following',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -110,9 +104,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -147,7 +139,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'erics273 Friends',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -163,9 +154,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -200,7 +189,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'erics273 Gamerscore',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'points',
}),
'context': <ANY>,
@@ -482,9 +470,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -519,7 +505,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GSR Ae Follower',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -535,9 +520,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -572,7 +555,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GSR Ae Following',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -588,9 +570,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -625,7 +605,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GSR Ae Friends',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -641,9 +620,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -678,7 +655,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GSR Ae Gamerscore',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'points',
}),
'context': <ANY>,
@@ -961,9 +937,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -998,7 +972,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ikken Hissatsuu Follower',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -1014,9 +987,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -1051,7 +1022,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ikken Hissatsuu Following',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -1067,9 +1037,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -1104,7 +1072,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ikken Hissatsuu Friends',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'people',
}),
'context': <ANY>,
@@ -1120,9 +1087,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -1157,7 +1122,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ikken Hissatsuu Gamerscore',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'points',
}),
'context': <ANY>,

View File

@@ -1,24 +1,16 @@
"""Tests for the Xbox integration."""
from datetime import timedelta
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
from httpx import ConnectTimeout, HTTPStatusError, ProtocolError, RequestError, Response
from httpx import ConnectTimeout, HTTPStatusError, ProtocolError
import pytest
from pythonxbox.api.provider.smartglass.models import SmartglassConsoleList
from pythonxbox.common.exceptions import AuthenticationException
import respx
from homeassistant.components.xbox.const import DOMAIN, OAUTH2_TOKEN
from homeassistant.components.xbox.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -90,76 +82,6 @@ async def test_config_implementation_not_available(
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("state", "exception"),
[
(
ConfigEntryState.SETUP_ERROR,
OAuth2TokenRequestReauthError(domain=DOMAIN, request_info=Mock()),
),
(
ConfigEntryState.SETUP_RETRY,
OAuth2TokenRequestTransientError(domain=DOMAIN, request_info=Mock()),
),
(
ConfigEntryState.SETUP_RETRY,
ClientError,
),
],
)
@respx.mock
async def test_oauth_session_refresh_failure_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
state: ConfigEntryState,
exception: Exception | type[Exception],
oauth2_session: AsyncMock,
) -> None:
"""Test OAuth2 session refresh failures."""
oauth2_session.async_ensure_token_valid.side_effect = exception
oauth2_session.valid_token = False
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is state
@pytest.mark.parametrize(
("state", "exception"),
[
(
ConfigEntryState.SETUP_RETRY,
HTTPStatusError(
"", request=MagicMock(), response=Response(HTTPStatus.IM_A_TEAPOT)
),
),
(ConfigEntryState.SETUP_RETRY, RequestError("", request=Mock())),
(ConfigEntryState.SETUP_ERROR, AuthenticationException),
],
)
@respx.mock
async def test_oauth_session_refresh_user_and_xsts_token_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
state: ConfigEntryState,
exception: Exception | type[Exception],
oauth2_session: AsyncMock,
) -> None:
"""Test OAuth2 user and XSTS token refresh failures."""
oauth2_session.valid_token = True
respx.post(OAUTH2_TOKEN).mock(side_effect=exception)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is state
@pytest.mark.parametrize(
"exception",
[