Compare commits

..

1 Commits

Author SHA1 Message Date
Mike Degatano
22e7f5928e Remove aiohasupervisor from pyproject.toml 2026-03-13 23:24:14 +00:00
137 changed files with 679 additions and 9021 deletions

View File

@@ -570,7 +570,6 @@ homeassistant.components.trafikverket_train.*
homeassistant.components.trafikverket_weatherstation.*
homeassistant.components.transmission.*
homeassistant.components.trend.*
homeassistant.components.trmnl.*
homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifi.*

2
CODEOWNERS generated
View File

@@ -1770,8 +1770,6 @@ build.json @home-assistant/supervisor
/tests/components/trend/ @jpbede
/homeassistant/components/triggercmd/ @rvmey
/tests/components/triggercmd/ @rvmey
/homeassistant/components/trmnl/ @joostlek
/tests/components/trmnl/ @joostlek
/homeassistant/components/tts/ @home-assistant/core
/tests/components/tts/ @home-assistant/core
/homeassistant/components/tuya/ @Tuya @zlinoliver

View File

@@ -1,12 +1,5 @@
{
"domain": "ubiquiti",
"name": "Ubiquiti",
"integrations": [
"airos",
"unifi",
"unifi_access",
"unifi_direct",
"unifiled",
"unifiprotect"
]
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
}

View File

@@ -1,5 +1,6 @@
"""Defines a base Alexa Devices entity."""
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
from aioamazondevices.structures import AmazonDevice
from homeassistant.helpers.device_registry import DeviceInfo
@@ -24,15 +25,20 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_num = serial_num
model = self.device.model
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_num)},
name=self.device.account_name,
model=self.device.model,
model=model,
model_id=self.device.device_type,
manufacturer=self.device.manufacturer or "Amazon",
hw_version=self.device.hardware_version,
sw_version=self.device.software_version,
serial_number=serial_num,
sw_version=(
self.device.software_version
if model != SPEAKER_GROUP_DEVICE_TYPE
else None
),
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
)
self.entity_description = description
self._attr_unique_id = f"{serial_num}-{description.key}"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.0.1"]
"requirements": ["aioamazondevices==13.0.0"]
}

View File

@@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:

View File

@@ -1,68 +0,0 @@
"""Arcam binary sensors for incoming stream info."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from arcam.fmj.state import State
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArcamFmjConfigEntry
from .entity import ArcamFmjEntity
@dataclass(frozen=True, kw_only=True)
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an Arcam FMJ binary sensor entity."""
value_fn: Callable[[State], bool | None]
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
ArcamFmjBinarySensorEntityDescription(
key="incoming_video_interlaced",
translation_key="incoming_video_interlaced",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: (
vp.interlaced
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ArcamFmjConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Arcam FMJ binary sensors from a config entry."""
coordinators = config_entry.runtime_data.coordinators
entities: list[ArcamFmjBinarySensorEntity] = []
for coordinator in coordinators.values():
entities.extend(
ArcamFmjBinarySensorEntity(coordinator, description)
for description in BINARY_SENSORS
)
async_add_entities(entities)
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
"""Representation of an Arcam FMJ binary sensor."""
entity_description: ArcamFmjBinarySensorEntityDescription
@property
def is_on(self) -> bool | None:
"""Return the binary sensor value."""
return self.entity_description.value_fn(self.coordinator.state)

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ArcamFmjCoordinator
@@ -13,16 +12,9 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
_attr_has_entity_name = True
def __init__(
self,
coordinator: ArcamFmjCoordinator,
description: EntityDescription | None = None,
) -> None:
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
self._attr_unique_id = coordinator.zone_unique_id
if description is not None:
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
self.entity_description = description

View File

@@ -1,162 +0,0 @@
"""Arcam sensors for incoming stream info."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfFrequency
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArcamFmjConfigEntry
from .entity import ArcamFmjEntity
@dataclass(frozen=True, kw_only=True)
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
"""Describes an Arcam FMJ sensor entity."""
value_fn: Callable[[State], int | float | str | None]
SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
ArcamFmjSensorEntityDescription(
key="incoming_video_horizontal_resolution",
translation_key="incoming_video_horizontal_resolution",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="px",
suggested_display_precision=0,
value_fn=lambda state: (
vp.horizontal_resolution
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_video_vertical_resolution",
translation_key="incoming_video_vertical_resolution",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="px",
suggested_display_precision=0,
value_fn=lambda state: (
vp.vertical_resolution
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_video_refresh_rate",
translation_key="incoming_video_refresh_rate",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
suggested_display_precision=0,
value_fn=lambda state: (
vp.refresh_rate
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_video_aspect_ratio",
translation_key="incoming_video_aspect_ratio",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingVideoAspectRatio],
value_fn=lambda state: (
vp.aspect_ratio.name.lower()
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_video_colorspace",
translation_key="incoming_video_colorspace",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingVideoColorspace],
value_fn=lambda state: (
vp.colorspace.name.lower()
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_format",
translation_key="incoming_audio_format",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingAudioFormat],
value_fn=lambda state: (
result.name.lower()
if (result := state.get_incoming_audio_format()[0]) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_config",
translation_key="incoming_audio_config",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingAudioConfig],
value_fn=lambda state: (
result.name.lower()
if (result := state.get_incoming_audio_format()[1]) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_sample_rate",
translation_key="incoming_audio_sample_rate",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
suggested_display_precision=0,
value_fn=lambda state: (
None
if (sample_rate := state.get_incoming_audio_sample_rate()) == 0
else sample_rate
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ArcamFmjConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Arcam FMJ sensors from a config entry."""
coordinators = config_entry.runtime_data.coordinators
entities: list[ArcamFmjSensorEntity] = []
for coordinator in coordinators.values():
entities.extend(
ArcamFmjSensorEntity(coordinator, description) for description in SENSORS
)
async_add_entities(entities)
class ArcamFmjSensorEntity(ArcamFmjEntity, SensorEntity):
"""Representation of an Arcam FMJ sensor."""
entity_description: ArcamFmjSensorEntityDescription
@property
def native_value(self) -> int | float | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.state)

View File

@@ -23,121 +23,5 @@
"trigger_type": {
"turn_on": "{entity_name} was requested to turn on"
}
},
"entity": {
"binary_sensor": {
"incoming_video_interlaced": {
"name": "Incoming video interlaced"
}
},
"sensor": {
"incoming_audio_config": {
"name": "Incoming audio configuration",
"state": {
"auro_10_1": "Auro 10.1",
"auro_11_1": "Auro 11.1",
"auro_13_1": "Auro 13.1",
"auro_2_2_2": "Auro 2.2.2",
"auro_5_0": "Auro 5.0",
"auro_5_1": "Auro 5.1",
"auro_8_0": "Auro 8.0",
"auro_9_1": "Auro 9.1",
"auro_quad": "Auro quad",
"dual_mono": "Dual mono",
"dual_mono_lfe": "Dual mono + LFE",
"mono": "Mono",
"mono_lfe": "Mono + LFE",
"stereo_center": "Stereo center",
"stereo_center_lfe": "Stereo center + LFE",
"stereo_center_surr_lr": "Stereo center surround L/R",
"stereo_center_surr_lr_back_lr": "Stereo center surround L/R back L/R",
"stereo_center_surr_lr_back_lr_lfe": "Stereo center surround L/R back L/R + LFE",
"stereo_center_surr_lr_back_matrix": "Stereo center surround L/R back matrix",
"stereo_center_surr_lr_back_matrix_lfe": "Stereo center surround L/R back matrix + LFE",
"stereo_center_surr_lr_back_mono": "Stereo center surround L/R back mono",
"stereo_center_surr_lr_back_mono_lfe": "Stereo center surround L/R back mono + LFE",
"stereo_center_surr_lr_lfe": "Stereo center surround L/R + LFE",
"stereo_center_surr_mono": "Stereo center surround mono",
"stereo_center_surr_mono_lfe": "Stereo center surround mono + LFE",
"stereo_downmix": "Stereo downmix",
"stereo_downmix_lfe": "Stereo downmix + LFE",
"stereo_lfe": "Stereo + LFE",
"stereo_only": "Stereo only",
"stereo_only_lo_ro": "Stereo only Lo/Ro",
"stereo_only_lo_ro_lfe": "Stereo only Lo/Ro + LFE",
"stereo_surr_lr": "Stereo surround L/R",
"stereo_surr_lr_back_lr": "Stereo surround L/R back L/R",
"stereo_surr_lr_back_lr_lfe": "Stereo surround L/R back L/R + LFE",
"stereo_surr_lr_back_matrix": "Stereo surround L/R back matrix",
"stereo_surr_lr_back_matrix_lfe": "Stereo surround L/R back matrix + LFE",
"stereo_surr_lr_back_mono": "Stereo surround L/R back mono",
"stereo_surr_lr_back_mono_lfe": "Stereo surround L/R back mono + LFE",
"stereo_surr_lr_lfe": "Stereo surround L/R + LFE",
"stereo_surr_mono": "Stereo surround mono",
"stereo_surr_mono_lfe": "Stereo surround mono + LFE",
"undetected": "Undetected",
"unknown": "Unknown"
}
},
"incoming_audio_format": {
"name": "Incoming audio format",
"state": {
"analogue_direct": "Analogue direct",
"auro_3d": "Auro-3D",
"dolby_atmos": "Dolby Atmos",
"dolby_digital": "Dolby Digital",
"dolby_digital_ex": "Dolby Digital EX",
"dolby_digital_plus": "Dolby Digital Plus",
"dolby_digital_surround": "Dolby Digital Surround",
"dolby_digital_true_hd": "Dolby TrueHD",
"dts": "DTS",
"dts_96_24": "DTS 96/24",
"dts_core": "DTS Core",
"dts_es_discrete": "DTS-ES Discrete",
"dts_es_discrete_96_24": "DTS-ES Discrete 96/24",
"dts_es_matrix": "DTS-ES Matrix",
"dts_es_matrix_96_24": "DTS-ES Matrix 96/24",
"dts_hd_high_res_audio": "DTS-HD High Resolution Audio",
"dts_hd_master_audio": "DTS-HD Master Audio",
"dts_low_bit_rate": "DTS Low Bit Rate",
"dts_x": "DTS:X",
"imax_enhanced": "IMAX Enhanced",
"pcm": "PCM",
"pcm_zero": "PCM zero",
"undetected": "Undetected",
"unsupported": "Unsupported"
}
},
"incoming_audio_sample_rate": {
"name": "Incoming audio sample rate"
},
"incoming_video_aspect_ratio": {
"name": "Incoming video aspect ratio",
"state": {
"aspect_16_9": "16:9",
"aspect_4_3": "4:3",
"undefined": "Undefined"
}
},
"incoming_video_colorspace": {
"name": "Incoming video colorspace",
"state": {
"dolby_vision": "Dolby Vision",
"hdr10": "HDR10",
"hdr10_plus": "HDR10+",
"hlg": "HLG",
"normal": "Normal"
}
},
"incoming_video_horizontal_resolution": {
"name": "Incoming video horizontal resolution"
},
"incoming_video_refresh_rate": {
"name": "Incoming video refresh rate"
},
"incoming_video_vertical_resolution": {
"name": "Incoming video vertical resolution"
}
}
}
}

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.10.2"
"habluetooth==5.9.1"
]
}

View File

@@ -1,22 +0,0 @@
"""Diagnostics support for Chess.com."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import ChessConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ChessConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"player": asdict(coordinator.data.player),
"stats": asdict(coordinator.data.stats),
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Can't detect a game

View File

@@ -15,6 +15,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
INTENT_OPEN_COVER,
DOMAIN,
SERVICE_OPEN_COVER,
"Opening {}",
description="Opens a cover",
platforms={DOMAIN},
device_classes={CoverDeviceClass},
@@ -26,6 +27,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
INTENT_CLOSE_COVER,
DOMAIN,
SERVICE_CLOSE_COVER,
"Closing {}",
description="Closes a cover",
platforms={DOMAIN},
device_classes={CoverDeviceClass},

View File

@@ -103,8 +103,6 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Initialize flow from zeroconf."""
zeroconf_properties = discovery_info.properties
host = zeroconf_properties.get("api_domain")
if not host:
return self.async_abort(reason="missing_api_domain")
port = zeroconf_properties.get("https_port") or discovery_info.port
host = zeroconf_properties["api_domain"]
port = zeroconf_properties["https_port"]
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})

View File

@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"missing_api_domain": "The discovered Freebox service did not provide the required API domain. Try again later or configure the Freebox manually."
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -46,12 +46,10 @@ PROGRAM_OPTIONS = {
value,
)
for key, value in {
OptionKey.BSH_COMMON_DURATION: vol.All(int, vol.Range(min=0)),
OptionKey.BSH_COMMON_START_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: vol.All(
int, vol.Range(min=0)
),
OptionKey.BSH_COMMON_DURATION: int,
OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
@@ -62,10 +60,7 @@ PROGRAM_OPTIONS = {
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE: vol.All(
int, vol.Range(min=1, max=100)
),
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==1.6.0",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -114,6 +114,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
IntellifireSensorEntityDescription(
key="timer_end_timestamp",
translation_key="timer_end_timestamp",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=_time_remaining_to_timestamp,
),

View File

@@ -10,7 +10,7 @@ from pylitterbot import Account
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -21,7 +21,6 @@ _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)
STEP_REAUTH_RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -44,45 +43,24 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Handle user's reauth credentials."""
errors: dict[str, str] = {}
errors = {}
if user_input:
reauth_entry = self._get_reauth_entry()
result, errors = await self._async_validate_and_update_entry(
reauth_entry, user_input
)
if result is not None:
return result
user_input = user_input | {CONF_USERNAME: self.username}
if not (error := await self._async_validate_input(user_input)):
await self.async_set_unique_id(self._account_user_id)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
errors["base"] = error
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA,
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={CONF_USERNAME: self.username},
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow request."""
reconfigure_entry = self._get_reconfigure_entry()
self.username = reconfigure_entry.data[CONF_USERNAME]
self._async_abort_entries_match({CONF_USERNAME: self.username})
errors: dict[str, str] = {}
if user_input:
result, errors = await self._async_validate_and_update_entry(
reconfigure_entry, user_input
)
if result is not None:
return result
return self.async_show_form(
step_id="reconfigure",
data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA,
errors=errors,
)
async def async_step_user(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
@@ -103,25 +81,6 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def _async_validate_and_update_entry(
self, entry: ConfigEntry, user_input: dict[str, Any]
) -> tuple[ConfigFlowResult | None, dict[str, str]]:
"""Validate credentials and update an existing entry if valid."""
errors: dict[str, str] = {}
full_input: dict[str, Any] = user_input | {CONF_USERNAME: self.username}
if not (error := await self._async_validate_input(full_input)):
await self.async_set_unique_id(self._account_user_id)
self._abort_if_unique_id_mismatch()
return (
self.async_update_reload_and_abort(
entry,
data_updates=full_input,
),
errors,
)
errors["base"] = error
return None, errors
async def _async_validate_input(self, user_input: Mapping[str, Any]) -> str:
"""Validate login credentials."""
account = Account(websession=async_get_clientsession(self.hass))

View File

@@ -53,14 +53,10 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
# Need to fetch weight history for `get_visits_since`
await pet.fetch_weight_history()
except LitterRobotLoginException as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_credentials"
) from ex
raise ConfigEntryAuthFailed("Invalid credentials") from ex
except LitterRobotException as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": str(ex)},
f"Unable to fetch data from the Whisker API: {ex}"
) from ex
async def _async_setup(self) -> None:
@@ -74,15 +70,9 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
load_pets=True,
)
except LitterRobotLoginException as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_credentials"
) from ex
raise ConfigEntryAuthFailed("Invalid credentials") from ex
except LitterRobotException as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": str(ex)},
) from ex
raise UpdateFailed("Unable to connect to Whisker API") from ex
def litter_robots(self) -> Generator[LitterRobot]:
"""Get Litter-Robots from the account."""

View File

@@ -12,6 +12,6 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["pylitterbot==2025.1.0"]
}

View File

@@ -34,7 +34,11 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
test-coverage:
status: todo
comment: |
Move big data objects from common.py into JSON fixtures and oad them when needed.
Other fields can be moved to const.py. Consider snapshots and testing data updates
# Gold
devices: done
@@ -45,21 +49,23 @@ rules:
discovery:
status: todo
comment: Need to validate discovery
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
entity-translations:
status: todo
comment: Make sure all translated states are in sentence case
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: done
comment: |

View File

@@ -3,7 +3,6 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The Whisker account does not match the previously configured account. Please re-authenticate using the same account, or remove this integration and set it up again if you want to use a different account."
},
"error": {
@@ -22,14 +21,6 @@
"description": "Please update your password for {username}",
"title": "[%key:common::config_flow::title::reauth%]"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::litterrobot::config::step::user::data_description::password%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -205,17 +196,11 @@
}
},
"exceptions": {
"cannot_connect": {
"message": "Unable to fetch data from the Whisker API: {error}"
},
"command_failed": {
"message": "An error occurred while communicating with the device: {error}"
},
"firmware_update_failed": {
"message": "Unable to start firmware update on {name}"
},
"invalid_credentials": {
"message": "Invalid credentials. Please check your username and password, then try again"
}
},
"issues": {

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.2.2"]
"requirements": ["aiomealie==1.2.1"]
}

View File

@@ -500,7 +500,6 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
pasta_paela = 14
tall_items = 17, 42
glasses_warm = 19
quick_intense = 21
normal = 30
power_wash = 44, 204
comfort_wash = 203

View File

@@ -759,7 +759,6 @@
"pyrolytic": "Pyrolytic",
"quiche_lorraine": "Quiche Lorraine",
"quick_hygiene": "QuickHygiene",
"quick_intense": "QuickIntense",
"quick_mw": "Quick MW",
"quick_power_dry": "QuickPowerDry",
"quick_power_wash": "QuickPowerWash",

View File

@@ -180,9 +180,6 @@ class OneDriveBackupAgent(BackupAgent):
upload_chunk_size=upload_chunk_size,
session=async_get_clientsession(self._hass),
smart_chunk_size=True,
progress_callback=lambda bytes_uploaded: on_progress(
bytes_uploaded=bytes_uploaded
),
)
except HashMismatchError as err:
raise BackupAgentError(

View File

@@ -174,9 +174,6 @@ class OneDriveBackupAgent(BackupAgent):
upload_chunk_size=upload_chunk_size,
session=async_get_clientsession(self._hass),
smart_chunk_size=True,
progress_callback=lambda bytes_uploaded: on_progress(
bytes_uploaded=bytes_uploaded
),
)
except HashMismatchError as err:
raise BackupAgentError(

View File

@@ -18,7 +18,6 @@ TO_REDACT = {
CONF_TOTP_SECRET,
# Title contains the username/email
"title",
"utility_account_id",
}
@@ -28,46 +27,43 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return async_redact_data(
{
"entry": entry.as_dict(),
"data": [
{
"account": {
"utility_account_id": account.utility_account_id,
"meter_type": account.meter_type.name,
"read_resolution": (
account.read_resolution.name
if account.read_resolution
else None
),
},
"forecast": (
{
"usage_to_date": forecast.usage_to_date,
"cost_to_date": forecast.cost_to_date,
"forecasted_usage": forecast.forecasted_usage,
"forecasted_cost": forecast.forecasted_cost,
"typical_usage": forecast.typical_usage,
"typical_cost": forecast.typical_cost,
"unit_of_measure": forecast.unit_of_measure.name,
"start_date": forecast.start_date.isoformat(),
"end_date": forecast.end_date.isoformat(),
"current_date": forecast.current_date.isoformat(),
}
if (forecast := data.forecast)
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"data": {
account_id: {
"account": {
"utility_account_id": account.utility_account_id,
"meter_type": account.meter_type.name,
"read_resolution": (
account.read_resolution.name
if account.read_resolution
else None
),
"last_changed": (
data.last_changed.isoformat() if data.last_changed else None
),
"last_updated": (
data.last_updated.isoformat() if data.last_updated else None
),
}
for data in coordinator.data.values()
for account in (data.account,)
],
},
"forecast": (
{
"usage_to_date": forecast.usage_to_date,
"cost_to_date": forecast.cost_to_date,
"forecasted_usage": forecast.forecasted_usage,
"forecasted_cost": forecast.forecasted_cost,
"typical_usage": forecast.typical_usage,
"typical_cost": forecast.typical_cost,
"unit_of_measure": forecast.unit_of_measure.name,
"start_date": forecast.start_date.isoformat(),
"end_date": forecast.end_date.isoformat(),
"current_date": forecast.current_date.isoformat(),
}
if (forecast := data.forecast)
else None
),
"last_changed": (
data.last_changed.isoformat() if data.last_changed else None
),
"last_updated": (
data.last_updated.isoformat() if data.last_updated else None
),
}
for account_id, data in coordinator.data.items()
for account in (data.account,)
},
TO_REDACT,
)
}

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["python-pooldose==0.8.5"]
"requirements": ["python-pooldose==0.8.2"]
}

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import logging
from pyportainer import Portainer
from pyportainer.exceptions import PortainerError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -139,26 +138,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry)
hass.config_entries.async_update_entry(entry=entry, version=4)
if entry.version < 5:
client = Portainer(
api_url=entry.data[CONF_URL],
api_key=entry.data[CONF_API_TOKEN],
session=async_create_clientsession(
hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
),
)
try:
system_status = await client.portainer_system_status()
except PortainerError:
_LOGGER.exception("Failed to fetch instance ID during migration")
return False
hass.config_entries.async_update_entry(
entry=entry,
unique_id=system_status.instance_id,
version=5,
)
return True

View File

@@ -12,7 +12,6 @@ from pyportainer import (
PortainerConnectionError,
PortainerTimeoutError,
)
from pyportainer.models.portainer import PortainerSystemStatus
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -33,9 +32,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
)
async def _validate_input(
hass: HomeAssistant, data: dict[str, Any]
) -> PortainerSystemStatus:
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect."""
client = Portainer(
@@ -44,7 +41,7 @@ async def _validate_input(
session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]),
)
try:
system_status = await client.portainer_system_status()
await client.get_endpoints()
except PortainerAuthenticationError:
raise InvalidAuth from None
except PortainerConnectionError as err:
@@ -53,13 +50,12 @@ async def _validate_input(
raise PortainerTimeout from err
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL])
return system_status
class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Portainer."""
VERSION = 5
VERSION = 4
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -67,8 +63,9 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
system_status = await _validate_input(self.hass, user_input)
await _validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -79,7 +76,7 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(system_status.instance_id)
await self.async_set_unique_id(user_input[CONF_API_TOKEN])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_URL], data=user_input
@@ -145,7 +142,7 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
try:
system_status = await _validate_input(
await _validate_input(
self.hass,
data={
**reconf_entry.data,
@@ -162,8 +159,12 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(system_status.instance_id)
self._abort_if_unique_id_mismatch()
# Logic that can be reverted back once the new unique ID is in
existing_entry = await self.async_set_unique_id(
user_input[CONF_API_TOKEN]
)
if existing_entry and existing_entry.entry_id != reconf_entry.entry_id:
return self.async_abort(reason="already_configured")
return self.async_update_reload_and_abort(
reconf_entry,
data_updates={

View File

@@ -3,8 +3,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The Portainer instance ID does not match the previously configured instance. This can occur if the device was reset or reconfigured outside of Home Assistant."
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -8,7 +8,7 @@
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.41",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==1.6.0",
"psutil-home-assistant==0.0.1"
]
}

View File

@@ -200,14 +200,6 @@ CAPABILITY_TO_SENSORS: dict[
supported_states_attributes=Attribute.SUPPORTED_STATUS,
)
},
Capability.CUSTOM_COOKTOP_OPERATING_STATE: {
Attribute.COOKTOP_OPERATING_STATE: SmartThingsBinarySensorEntityDescription(
key=Attribute.COOKTOP_OPERATING_STATE,
translation_key="cooktop_operating_state",
is_on_key="run",
supported_states_attributes=Attribute.SUPPORTED_COOKTOP_OPERATING_STATE,
)
},
}

View File

@@ -25,10 +25,6 @@
"hostname": "hub*",
"macaddress": "286D97*"
},
{
"hostname": "smarthub",
"macaddress": "683A48*"
},
{
"hostname": "samsung-*"
}

View File

@@ -49,9 +49,6 @@
"child_lock": {
"name": "Child lock"
},
"cooktop_operating_state": {
"name": "[%key:component::smartthings::entity::sensor::cooktop_operating_state::name%]"
},
"cool_select_plus_door": {
"name": "CoolSelect+ door"
},

View File

@@ -7,6 +7,7 @@ import io
import logging
import os
from pathlib import Path
from ssl import SSLContext
from types import MappingProxyType
from typing import Any, cast
@@ -47,8 +48,8 @@ from homeassistant.const import (
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.json import JsonValueType
from homeassistant.util.ssl import get_default_context, get_default_no_verify_context
from .const import (
ATTR_ARGS,
@@ -565,7 +566,11 @@ class TelegramNotificationService:
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False),
verify_ssl=(
get_default_context()
if kwargs.get(ATTR_VERIFY_SSL, False)
else get_default_no_verify_context()
),
)
media: InputMedia
@@ -733,7 +738,11 @@ class TelegramNotificationService:
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False),
verify_ssl=(
get_default_context()
if kwargs.get(ATTR_VERIFY_SSL, False)
else get_default_no_verify_context()
),
)
if file_type == SERVICE_SEND_PHOTO:
@@ -1046,7 +1055,7 @@ async def load_data(
username: str,
password: str,
authentication: str | None,
verify_ssl: bool,
verify_ssl: SSLContext,
num_retries: int = 5,
) -> io.BytesIO:
"""Load data into ByteIO/File container from a source."""
@@ -1062,13 +1071,16 @@ async def load_data(
elif authentication == HTTP_BASIC_AUTHENTICATION:
params["auth"] = httpx.BasicAuth(username, password)
if verify_ssl is not None:
params["verify"] = verify_ssl
retry_num = 0
async with get_async_client(hass, verify_ssl) as client:
async with httpx.AsyncClient(
timeout=DEFAULT_TIMEOUT_SECONDS, headers=headers, **params
) as client:
while retry_num < num_retries:
try:
response = await client.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT_SECONDS, **params
)
req = await client.get(url)
except (httpx.HTTPError, httpx.InvalidURL) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -1076,15 +1088,15 @@ async def load_data(
translation_placeholders={"error": str(err)},
) from err
if response.status_code != 200:
if req.status_code != 200:
_LOGGER.warning(
"Status code %s (retry #%s) loading %s",
response.status_code,
req.status_code,
retry_num + 1,
url,
)
else:
data = io.BytesIO(response.content)
data = io.BytesIO(req.content)
if data.read():
data.seek(0)
data.name = url
@@ -1099,7 +1111,7 @@ async def load_data(
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_load_url",
translation_placeholders={"error": str(response.status_code)},
translation_placeholders={"error": str(req.status_code)},
)
elif filepath is not None:
if hass.config.is_allowed_path(filepath):

View File

@@ -1,29 +0,0 @@
"""The TRMNL integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import TRMNLConfigEntry, TRMNLCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME]
async def async_setup_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool:
"""Set up TRMNL from a config entry."""
coordinator = TRMNLCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,84 +0,0 @@
"""Config flow for TRMNL."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from trmnl import TRMNLClient
from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
TRMNL_ACCOUNT_URL = "https://trmnl.com/account"
class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
"""TRMNL config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user, reauth, or reconfigure."""
errors: dict[str, str] = {}
if user_input:
session = async_get_clientsession(self.hass)
client = TRMNLClient(token=user_input[CONF_API_KEY], session=session)
try:
user = await client.get_me()
except TRMNLAuthenticationError:
errors["base"] = "invalid_auth"
except TRMNLError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(str(user.identifier))
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user.name,
data={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
description_placeholders={"account_url": TRMNL_ACCOUNT_URL},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user()

View File

@@ -1,7 +0,0 @@
"""Constants for the TRMNL integration."""
import logging
DOMAIN = "trmnl"
LOGGER = logging.getLogger(__package__)

View File

@@ -1,69 +0,0 @@
"""Define an object to manage fetching TRMNL data."""
from __future__ import annotations
from datetime import timedelta
from trmnl import TRMNLClient
from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError
from trmnl.models import Device
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
type TRMNLConfigEntry = ConfigEntry[TRMNLCoordinator]
class TRMNLCoordinator(DataUpdateCoordinator[dict[int, Device]]):
"""Class to manage fetching TRMNL data."""
config_entry: TRMNLConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: TRMNLConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
logger=LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(hours=1),
)
self.client = TRMNLClient(
token=config_entry.data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
async def _async_update_data(self) -> dict[int, Device]:
"""Fetch data from TRMNL."""
try:
devices = await self.client.get_devices()
except TRMNLAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from err
except TRMNLError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
new_data = {device.identifier: device for device in devices}
if self.data is not None:
device_registry = dr.async_get(self.hass)
for device_id in set(self.data) - set(new_data):
if entry := device_registry.async_get_device(
identifiers={(DOMAIN, str(device_id))}
):
device_registry.async_update_device(
device_id=entry.id,
remove_config_entry_id=self.config_entry.entry_id,
)
return new_data

View File

@@ -1,25 +0,0 @@
"""Diagnostics support for TRMNL."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import TRMNLConfigEntry
TO_REDACT = {"mac_address"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: TRMNLConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"data": [
async_redact_data(asdict(device), TO_REDACT)
for device in entry.runtime_data.data.values()
],
}

View File

@@ -1,65 +0,0 @@
"""Base class for TRMNL entities."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from trmnl.exceptions import TRMNLError
from trmnl.models import Device
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TRMNLCoordinator
class TRMNLEntity(CoordinatorEntity[TRMNLCoordinator]):
"""Defines a base TRMNL entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: TRMNLCoordinator, device_id: int) -> None:
"""Initialize TRMNL entity."""
super().__init__(coordinator)
self._device_id = device_id
device = self._device
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
identifiers={(DOMAIN, str(device_id))},
name=device.name,
manufacturer="TRMNL",
)
@property
def _device(self) -> Device:
"""Return the device from coordinator data."""
return self.coordinator.data[self._device_id]
@property
def available(self) -> bool:
"""Return if the device is available."""
return super().available and self._device_id in self.coordinator.data
def exception_handler[_EntityT: TRMNLEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate TRMNL calls to handle exceptions.
A decorator that wraps the passed in function, catches TRMNL errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except TRMNLError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_error",
translation_placeholders={"error": str(error)},
) from error
return handler

View File

@@ -1,31 +0,0 @@
{
"entity": {
"sensor": {
"wifi_strength": {
"default": "mdi:wifi-strength-off-outline",
"range": {
"0": "mdi:wifi-strength-1",
"25": "mdi:wifi-strength-2",
"50": "mdi:wifi-strength-3",
"75": "mdi:wifi-strength-4"
}
}
},
"switch": {
"sleep_mode": {
"default": "mdi:sleep-off",
"state": {
"on": "mdi:sleep"
}
}
},
"time": {
"sleep_end_time": {
"default": "mdi:sleep-off"
},
"sleep_start_time": {
"default": "mdi:sleep"
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"domain": "trmnl",
"name": "TRMNL",
"codeowners": ["@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/trmnl",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["trmnl==0.1.1"]
}

View File

@@ -1,74 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: There are no configuration parameters
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: Uses the cloud API
discovery:
status: exempt
comment: Can't be discovered
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: There are no repairable issues
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -1,122 +0,0 @@
"""Support for TRMNL sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from trmnl.models import Device
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfElectricPotential,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TRMNLConfigEntry
from .coordinator import TRMNLCoordinator
from .entity import TRMNLEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class TRMNLSensorEntityDescription(SensorEntityDescription):
"""Describes a TRMNL sensor entity."""
value_fn: Callable[[Device], int | float | None]
SENSOR_DESCRIPTIONS: tuple[TRMNLSensorEntityDescription, ...] = (
TRMNLSensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda device: device.percent_charged,
),
TRMNLSensorEntityDescription(
key="battery_voltage",
translation_key="battery_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda device: device.battery_voltage,
),
TRMNLSensorEntityDescription(
key="rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda device: device.rssi,
),
TRMNLSensorEntityDescription(
key="wifi_strength",
translation_key="wifi_strength",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda device: device.wifi_strength,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: TRMNLConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TRMNL sensor entities based on a config entry."""
coordinator = entry.runtime_data
known_device_ids: set[int] = set()
def _async_entity_listener() -> None:
new_ids = set(coordinator.data) - known_device_ids
if new_ids:
async_add_entities(
TRMNLSensor(coordinator, device_id, description)
for device_id in new_ids
for description in SENSOR_DESCRIPTIONS
)
known_device_ids.update(new_ids)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class TRMNLSensor(TRMNLEntity, SensorEntity):
"""Defines a TRMNL sensor."""
entity_description: TRMNLSensorEntityDescription
def __init__(
self,
coordinator: TRMNLCoordinator,
device_id: int,
description: TRMNLSensorEntityDescription,
) -> None:
"""Initialize TRMNL sensor."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def native_value(self) -> int | float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._device)

View File

@@ -1,63 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The API key belongs to a different account. Please use the API key for the original account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key for your TRMNL account."
},
"description": "You can find your API key on [your TRMNL account page]({account_url})."
}
}
},
"entity": {
"sensor": {
"battery_voltage": {
"name": "Battery voltage"
},
"wifi_strength": {
"name": "Wi-Fi strength"
}
},
"switch": {
"sleep_mode": {
"name": "Sleep mode"
}
},
"time": {
"sleep_end_time": {
"name": "Sleep end time"
},
"sleep_start_time": {
"name": "Sleep start time"
}
}
},
"exceptions": {
"action_error": {
"message": "An error occurred while communicating with TRMNL: {error}"
},
"authentication_error": {
"message": "Authentication failed. Please check your API key."
},
"update_error": {
"message": "An error occurred while communicating with TRMNL: {error}"
}
}
}

View File

@@ -1,103 +0,0 @@
"""Support for TRMNL switch entities."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from trmnl.models import Device
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TRMNLConfigEntry
from .coordinator import TRMNLCoordinator
from .entity import TRMNLEntity, exception_handler
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class TRMNLSwitchEntityDescription(SwitchEntityDescription):
"""Describes a TRMNL switch entity."""
value_fn: Callable[[Device], bool]
set_value_fn: Callable[[TRMNLCoordinator, int, bool], Coroutine[Any, Any, None]]
SWITCH_DESCRIPTIONS: tuple[TRMNLSwitchEntityDescription, ...] = (
TRMNLSwitchEntityDescription(
key="sleep_mode",
translation_key="sleep_mode",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.sleep_mode_enabled,
set_value_fn=lambda coordinator, device_id, value: (
coordinator.client.update_device(device_id, sleep_mode_enabled=value)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: TRMNLConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TRMNL switch entities based on a config entry."""
coordinator = entry.runtime_data
known_device_ids: set[int] = set()
def _async_entity_listener() -> None:
new_ids = set(coordinator.data) - known_device_ids
if new_ids:
async_add_entities(
TRMNLSwitchEntity(coordinator, device_id, description)
for device_id in new_ids
for description in SWITCH_DESCRIPTIONS
)
known_device_ids.update(new_ids)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class TRMNLSwitchEntity(TRMNLEntity, SwitchEntity):
"""Defines a TRMNL switch entity."""
entity_description: TRMNLSwitchEntityDescription
def __init__(
self,
coordinator: TRMNLCoordinator,
device_id: int,
description: TRMNLSwitchEntityDescription,
) -> None:
"""Initialize TRMNL switch entity."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return if sleep mode is enabled."""
return self.entity_description.value_fn(self._device)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable sleep mode."""
await self.entity_description.set_value_fn(
self.coordinator, self._device_id, True
)
await self.coordinator.async_request_refresh()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable sleep mode."""
await self.entity_description.set_value_fn(
self.coordinator, self._device_id, False
)
await self.coordinator.async_request_refresh()

View File

@@ -1,119 +0,0 @@
"""Support for TRMNL time entities."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import time
from typing import Any
from trmnl.models import Device
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TRMNLConfigEntry
from .coordinator import TRMNLCoordinator
from .entity import TRMNLEntity, exception_handler
PARALLEL_UPDATES = 0
def _minutes_to_time(minutes: int) -> time:
"""Convert minutes since midnight to a time object."""
return time(hour=minutes // 60, minute=minutes % 60)
def _time_to_minutes(value: time) -> int:
"""Convert a time object to minutes since midnight."""
return value.hour * 60 + value.minute
@dataclass(frozen=True, kw_only=True)
class TRMNLTimeEntityDescription(TimeEntityDescription):
"""Describes a TRMNL time entity."""
value_fn: Callable[[Device], time]
set_value_fn: Callable[[TRMNLCoordinator, int, time], Coroutine[Any, Any, None]]
TIME_DESCRIPTIONS: tuple[TRMNLTimeEntityDescription, ...] = (
TRMNLTimeEntityDescription(
key="sleep_start_time",
translation_key="sleep_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: _minutes_to_time(device.sleep_start_time),
set_value_fn=lambda coordinator, device_id, value: (
coordinator.client.update_device(
device_id, sleep_start_time=_time_to_minutes(value)
)
),
),
TRMNLTimeEntityDescription(
key="sleep_end_time",
translation_key="sleep_end_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: _minutes_to_time(device.sleep_end_time),
set_value_fn=lambda coordinator, device_id, value: (
coordinator.client.update_device(
device_id, sleep_end_time=_time_to_minutes(value)
)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: TRMNLConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TRMNL time entities based on a config entry."""
coordinator = entry.runtime_data
known_device_ids: set[int] = set()
def _async_entity_listener() -> None:
new_ids = set(coordinator.data) - known_device_ids
if new_ids:
async_add_entities(
TRMNLTimeEntity(coordinator, device_id, description)
for device_id in new_ids
for description in TIME_DESCRIPTIONS
)
known_device_ids.update(new_ids)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class TRMNLTimeEntity(TRMNLEntity, TimeEntity):
"""Defines a TRMNL time entity."""
entity_description: TRMNLTimeEntityDescription
def __init__(
self,
coordinator: TRMNLCoordinator,
device_id: int,
description: TRMNLTimeEntityDescription,
) -> None:
"""Initialize TRMNL time entity."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def native_value(self) -> time:
"""Return the current time value."""
return self.entity_description.value_fn(self._device)
@exception_handler
async def async_set_value(self, value: time) -> None:
"""Set the time value."""
await self.entity_description.set_value_fn(
self.coordinator, self._device_id, value
)
await self.coordinator.async_request_refresh()

View File

@@ -11,7 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT, Platform.SWITCH]
PLATFORMS: list[Platform] = [Platform.BUTTON]
async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from unifi_access_api import Door, UnifiAccessError
from unifi_access_api import ApiError, Door
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
@@ -24,8 +24,7 @@ async def async_setup_entry(
"""Set up UniFi Access button entities."""
coordinator = entry.runtime_data
async_add_entities(
UnifiAccessUnlockButton(coordinator, door)
for door in coordinator.data.doors.values()
UnifiAccessUnlockButton(coordinator, door) for door in coordinator.data.values()
)
@@ -46,7 +45,7 @@ class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity):
"""Unlock the door."""
try:
await self.coordinator.client.unlock_door(self._door_id)
except UnifiAccessError as err:
except ApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unlock_failed",

View File

@@ -3,33 +3,27 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import cast
from unifi_access_api import (
ApiAuthError,
ApiConnectionError,
ApiError,
Door,
EmergencyStatus,
UnifiAccessApiClient,
WsMessageHandler,
)
from unifi_access_api.models.websocket import (
HwDoorbell,
InsightsAdd,
LocationUpdateState,
LocationUpdateV2,
SettingUpdate,
V2LocationState,
V2LocationUpdate,
WebsocketMessage,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -39,25 +33,7 @@ _LOGGER = logging.getLogger(__name__)
type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator]
@dataclass(frozen=True)
class DoorEvent:
"""Represent a door event from WebSocket."""
door_id: str
category: str
event_type: str
event_data: dict[str, Any]
@dataclass(frozen=True)
class UnifiAccessData:
"""Data provided by the UniFi Access coordinator."""
doors: dict[str, Door]
emergency: EmergencyStatus
class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
class UnifiAccessCoordinator(DataUpdateCoordinator[dict[str, Door]]):
"""Coordinator for fetching UniFi Access door data."""
config_entry: UnifiAccessConfigEntry
@@ -77,29 +53,12 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
update_interval=None,
)
self.client = client
self._event_listeners: list[Callable[[DoorEvent], None]] = []
@callback
def async_subscribe_door_events(
self,
event_callback: Callable[[DoorEvent], None],
) -> CALLBACK_TYPE:
"""Subscribe to door events (doorbell, access)."""
def _unsubscribe() -> None:
self._event_listeners.remove(event_callback)
self._event_listeners.append(event_callback)
return _unsubscribe
async def _async_setup(self) -> None:
"""Set up the WebSocket connection for push updates."""
handlers: dict[str, WsMessageHandler] = {
"access.data.device.location_update_v2": self._handle_location_update,
"access.data.v2.location.update": self._handle_v2_location_update,
"access.hw.door_bell": self._handle_doorbell,
"access.logs.insights.add": self._handle_insights_add,
"access.data.setting.update": self._handle_setting_update,
}
self.client.start_websocket(
handlers,
@@ -107,26 +66,18 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
on_disconnect=self._on_ws_disconnect,
)
async def _async_update_data(self) -> UnifiAccessData:
"""Fetch all doors and emergency status from the API."""
async def _async_update_data(self) -> dict[str, Door]:
"""Fetch all doors from the API."""
try:
async with asyncio.timeout(10):
doors, emergency = await asyncio.gather(
self.client.get_doors(),
self.client.get_emergency_status(),
)
doors = await self.client.get_doors()
except ApiAuthError as err:
raise UpdateFailed(f"Authentication failed: {err}") from err
except ApiConnectionError as err:
raise UpdateFailed(f"Error connecting to API: {err}") from err
except ApiError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
except TimeoutError as err:
raise UpdateFailed("Timeout communicating with UniFi Access API") from err
return UnifiAccessData(
doors={door.id: door for door in doors},
emergency=emergency,
)
return {door.id: door for door in doors}
def _on_ws_connect(self) -> None:
"""Handle WebSocket connection established."""
@@ -140,7 +91,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
def _on_ws_disconnect(self) -> None:
"""Handle WebSocket disconnection."""
_LOGGER.warning("WebSocket disconnected from UniFi Access")
_LOGGER.debug("WebSocket disconnected from UniFi Access")
self.async_set_update_error(
UpdateFailed("WebSocket disconnected from UniFi Access")
)
@@ -159,13 +110,13 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
self, door_id: str, ws_state: LocationUpdateState | V2LocationState | None
) -> None:
"""Process a door state update from WebSocket."""
if self.data is None or door_id not in self.data.doors:
if self.data is None or door_id not in self.data:
return
if ws_state is None:
return
current_door = self.data.doors[door_id]
current_door = self.data[door_id]
updates: dict[str, object] = {}
if ws_state.dps is not None:
updates["door_position_status"] = ws_state.dps
@@ -173,68 +124,5 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
updates["door_lock_relay_status"] = "lock"
elif ws_state.lock == "unlocked":
updates["door_lock_relay_status"] = "unlock"
if not updates:
return
updated_door = current_door.with_updates(**updates)
self.async_set_updated_data(
UnifiAccessData(
doors={**self.data.doors, door_id: updated_door},
emergency=self.data.emergency,
)
)
async def _handle_setting_update(self, msg: WebsocketMessage) -> None:
"""Handle settings update messages (evacuation/lockdown)."""
if self.data is None:
return
update = cast(SettingUpdate, msg)
self.async_set_updated_data(
UnifiAccessData(
doors=self.data.doors,
emergency=EmergencyStatus(
evacuation=update.data.evacuation,
lockdown=update.data.lockdown,
),
)
)
async def _handle_doorbell(self, msg: WebsocketMessage) -> None:
"""Handle doorbell press events."""
doorbell = cast(HwDoorbell, msg)
self._dispatch_door_event(
doorbell.data.door_id,
"doorbell",
"ring",
{},
)
async def _handle_insights_add(self, msg: WebsocketMessage) -> None:
"""Handle access insights events (entry/exit)."""
insights = cast(InsightsAdd, msg)
door = insights.data.metadata.door
if not door.id:
return
event_type = (
"access_granted" if insights.data.result == "ACCESS" else "access_denied"
)
attrs: dict[str, Any] = {}
if insights.data.metadata.actor.display_name:
attrs["actor"] = insights.data.metadata.actor.display_name
if insights.data.metadata.authentication.display_name:
attrs["authentication"] = insights.data.metadata.authentication.display_name
if insights.data.result:
attrs["result"] = insights.data.result
self._dispatch_door_event(door.id, "access", event_type, attrs)
@callback
def _dispatch_door_event(
self,
door_id: str,
category: str,
event_type: str,
event_data: dict[str, Any],
) -> None:
"""Dispatch a door event to all subscribed listeners."""
event = DoorEvent(door_id, category, event_type, event_data)
for listener in self._event_listeners:
listener(event)
self.async_set_updated_data({**self.data, door_id: updated_door})

View File

@@ -35,24 +35,9 @@ class UnifiAccessEntity(CoordinatorEntity[UnifiAccessCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._door_id in self.coordinator.data.doors
return super().available and self._door_id in self.coordinator.data
@property
def _door(self) -> Door:
"""Return the current door state from coordinator data."""
return self.coordinator.data.doors[self._door_id]
class UnifiAccessHubEntity(CoordinatorEntity[UnifiAccessCoordinator]):
"""Base entity for hub-level (controller-wide) UniFi Access entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: UnifiAccessCoordinator) -> None:
"""Initialize the hub entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
name="UniFi Access",
manufacturer="Ubiquiti",
)
return self.coordinator.data[self._door_id]

View File

@@ -1,96 +0,0 @@
"""Event platform for the UniFi Access integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DoorEvent, UnifiAccessConfigEntry, UnifiAccessCoordinator
from .entity import UnifiAccessEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class UnifiAccessEventEntityDescription(EventEntityDescription):
"""Describes a UniFi Access event entity."""
category: str
DOORBELL_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription(
key="doorbell",
translation_key="doorbell",
device_class=EventDeviceClass.DOORBELL,
event_types=["ring"],
category="doorbell",
)
ACCESS_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription(
key="access",
translation_key="access",
event_types=["access_granted", "access_denied"],
category="access",
)
EVENT_DESCRIPTIONS: list[UnifiAccessEventEntityDescription] = [
DOORBELL_EVENT_DESCRIPTION,
ACCESS_EVENT_DESCRIPTION,
]
async def async_setup_entry(
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up UniFi Access event entities."""
coordinator = entry.runtime_data
async_add_entities(
UnifiAccessEventEntity(coordinator, door_id, description)
for door_id in coordinator.data.doors
for description in EVENT_DESCRIPTIONS
)
class UnifiAccessEventEntity(UnifiAccessEntity, EventEntity):
"""Representation of a UniFi Access event entity."""
entity_description: UnifiAccessEventEntityDescription
def __init__(
self,
coordinator: UnifiAccessCoordinator,
door_id: str,
description: UnifiAccessEventEntityDescription,
) -> None:
"""Initialize the event entity."""
door = coordinator.data.doors[door_id]
super().__init__(coordinator, door, description.key)
self.entity_description = description
async def async_added_to_hass(self) -> None:
"""Subscribe to door events when added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_subscribe_door_events(self._async_handle_event)
)
@callback
def _async_handle_event(self, event: DoorEvent) -> None:
"""Handle incoming event from coordinator."""
if (
event.door_id != self._door_id
or event.category != self.entity_description.category
or event.event_type not in self.event_types
):
return
self._trigger_event(event.event_type, event.event_data)
self.async_write_ha_state()

View File

@@ -4,19 +4,6 @@
"unlock": {
"default": "mdi:lock-open"
}
},
"event": {
"access": {
"default": "mdi:door"
}
},
"switch": {
"evacuation": {
"default": "mdi:exit-run"
},
"lockdown": {
"default": "mdi:lock-alert"
}
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["unifi_access_api"],
"quality_scale": "bronze",
"requirements": ["py-unifi-access==1.1.0"]
"requirements": ["py-unifi-access==1.0.0"]
}

View File

@@ -30,9 +30,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
@@ -55,7 +55,7 @@ rules:
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: done
icon-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

View File

@@ -28,43 +28,9 @@
"unlock": {
"name": "Unlock"
}
},
"event": {
"access": {
"name": "Access",
"state_attributes": {
"event_type": {
"state": {
"access_denied": "Access denied",
"access_granted": "Access granted"
}
}
}
},
"doorbell": {
"name": "Doorbell",
"state_attributes": {
"event_type": {
"state": {
"ring": "Ring"
}
}
}
}
},
"switch": {
"evacuation": {
"name": "Evacuation"
},
"lockdown": {
"name": "Lockdown"
}
}
},
"exceptions": {
"emergency_failed": {
"message": "Failed to set emergency status."
},
"unlock_failed": {
"message": "Failed to unlock the door."
}

View File

@@ -1,110 +0,0 @@
"""Switch platform for the UniFi Access integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from unifi_access_api import EmergencyStatus, UnifiAccessError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator, UnifiAccessData
from .entity import UnifiAccessHubEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class UnifiAccessSwitchEntityDescription(SwitchEntityDescription):
"""Describes a UniFi Access switch entity."""
value_fn: Callable[[EmergencyStatus], bool]
set_fn: Callable[[EmergencyStatus, bool], EmergencyStatus]
SWITCH_DESCRIPTIONS: tuple[UnifiAccessSwitchEntityDescription, ...] = (
UnifiAccessSwitchEntityDescription(
key="evacuation",
translation_key="evacuation",
value_fn=lambda s: s.evacuation,
set_fn=lambda s, v: EmergencyStatus(evacuation=v, lockdown=s.lockdown),
),
UnifiAccessSwitchEntityDescription(
key="lockdown",
translation_key="lockdown",
value_fn=lambda s: s.lockdown,
set_fn=lambda s, v: EmergencyStatus(evacuation=s.evacuation, lockdown=v),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up UniFi Access switch entities."""
coordinator = entry.runtime_data
async_add_entities(
UnifiAccessEmergencySwitch(coordinator, description)
for description in SWITCH_DESCRIPTIONS
)
class UnifiAccessEmergencySwitch(UnifiAccessHubEntity, SwitchEntity):
"""Representation of a UniFi Access emergency switch."""
entity_description: UnifiAccessSwitchEntityDescription
def __init__(
self,
coordinator: UnifiAccessCoordinator,
description: UnifiAccessSwitchEntityDescription,
) -> None:
"""Initialize the switch entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
self.entity_description = description
@property
def is_on(self) -> bool:
"""Return True if the switch is on."""
return self.entity_description.value_fn(self.coordinator.data.emergency)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_set_emergency(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_set_emergency(False)
async def _async_set_emergency(self, value: bool) -> None:
"""Set emergency status."""
new_status = self.entity_description.set_fn(
self.coordinator.data.emergency, value
)
try:
await self.coordinator.client.set_emergency_status(new_status)
except UnifiAccessError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="emergency_failed",
) from err
# Optimistically update state; the WebSocket confirmation via
# access.data.setting.update typically arrives ~200ms later.
# Guard against flipping coordinator.last_update_success back to True
# while the WebSocket is disconnected and all entities are unavailable.
if self.coordinator.last_update_success:
self.coordinator.async_set_updated_data(
UnifiAccessData(
doors=self.coordinator.data.doors,
emergency=new_status,
)
)

View File

@@ -116,6 +116,13 @@ SENSOR_DESCRIPTIONS: tuple[UptimeKumaSensorEntityDescription, ...] = (
value_fn=lambda m: m.monitor_port,
create_entity=lambda t: t in HAS_PORT,
),
UptimeKumaSensorEntityDescription(
key=UptimeKumaSensor.PORT,
translation_key=UptimeKumaSensor.PORT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda m: m.monitor_port,
create_entity=lambda t: t in HAS_PORT,
),
UptimeKumaSensorEntityDescription(
key=UptimeKumaSensor.UPTIME_RATIO_1D,
translation_key=UptimeKumaSensor.UPTIME_RATIO_1D,

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -73,58 +72,6 @@ class WaterFurnaceConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
client = WaterFurnace(username, password)
try:
await self.hass.async_add_executor_job(client.login)
except WFCredentialError:
errors["base"] = "invalid_auth"
except WFException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during reauthentication")
errors["base"] = "unknown"
# Treat no gwid as a connection failure
if not errors and not client.gwid:
errors["base"] = "cannot_connect"
if not errors:
await self.async_set_unique_id(client.gwid)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
reauth_entry,
title=f"WaterFurnace {username}",
data_updates={**reauth_entry.data, **user_input},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
{CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
),
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML configuration."""
username = import_data[CONF_USERNAME]

View File

@@ -4,9 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "Please verify your credentials.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "Unexpected error, please try again.",
"wrong_account": "You must reauthenticate with the same WaterFurnace account that was originally configured."
"unknown": "Unexpected error, please try again."
},
"error": {
"cannot_connect": "Failed to connect to WaterFurnace service",
@@ -14,18 +12,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::waterfurnace::config::step::user::data_description::password%]",
"username": "[%key:component::waterfurnace::config::step::user::data_description::username%]"
},
"description": "Please re-enter your WaterFurnace Symphony account credentials.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pywaze", "homeassistant.helpers.location"],
"requirements": ["pywaze==1.2.0"]
"requirements": ["pywaze==1.1.1"]
}

View File

@@ -156,7 +156,6 @@ class WebDavBackupAgent(BackupAgent):
f"{self._backup_path}/{filename_tar}",
timeout=BACKUP_TIMEOUT,
content_length=backup.size,
progress=lambda current, total: on_progress(bytes_uploaded=current),
)
_LOGGER.debug(

View File

@@ -942,7 +942,7 @@
"name": "Start-up color temperature"
},
"start_up_current_level": {
"name": "Power-on level"
"name": "Start-up current level"
},
"startup_time": {
"name": "Startup time"
@@ -1298,7 +1298,7 @@
"name": "Speed"
},
"start_up_on_off": {
"name": "Power-on behavior"
"name": "Start-up behavior"
},
"status_indication": {
"name": "Status indication"

View File

@@ -743,7 +743,6 @@ FLOWS = {
"trane",
"transmission",
"triggercmd",
"trmnl",
"tuya",
"twentemilieu",
"twilio",

View File

@@ -817,11 +817,6 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "hub*",
"macaddress": "286D97*",
},
{
"domain": "smartthings",
"hostname": "smarthub",
"macaddress": "683A48*",
},
{
"domain": "smartthings",
"hostname": "samsung-*",

View File

@@ -7244,12 +7244,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"trmnl": {
"name": "TRMNL",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"tuya": {
"name": "Tuya",
"integration_type": "hub",
@@ -7331,12 +7325,6 @@
"iot_class": "local_push",
"name": "UniFi Network"
},
"unifi_access": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"name": "UniFi Access"
},
"unifi_direct": {
"integration_type": "hub",
"config_flow": false,
@@ -7391,6 +7379,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"unifi_access": {
"name": "UniFi Access",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
"universal": {
"name": "Universal media player",
"integration_type": "hub",

View File

@@ -184,52 +184,6 @@ class IntentUnexpectedError(IntentError):
"""Unexpected error while handling intent."""
class MatchFailedError(IntentError):
"""Error when target matching fails."""
def __init__(
self,
result: MatchTargetsResult,
constraints: MatchTargetsConstraints,
preferences: MatchTargetsPreferences | None = None,
) -> None:
"""Initialize error."""
super().__init__()
self.result = result
self.constraints = constraints
self.preferences = preferences
def __str__(self) -> str:
"""Return string representation."""
return f"<MatchFailedError result={self.result}, constraints={self.constraints}, preferences={self.preferences}>"
class NoStatesMatchedError(MatchFailedError):
"""Error when no states match the intent's constraints."""
def __init__(
self,
reason: MatchFailedReason,
name: str | None = None,
area: str | None = None,
floor: str | None = None,
domains: set[str] | None = None,
device_classes: set[str] | None = None,
) -> None:
"""Initialize error."""
super().__init__(
result=MatchTargetsResult(False, reason),
constraints=MatchTargetsConstraints(
name=name,
area_name=area,
floor_name=floor,
domains=domains,
device_classes=device_classes,
),
)
class MatchFailedReason(Enum):
"""Possible reasons for match failure in async_match_targets."""
@@ -278,29 +232,6 @@ class MatchFailedReason(Enum):
)
@dataclass
class MatchTargetsResult:
"""Result from async_match_targets."""
is_match: bool
"""True if one or more entities matched."""
no_match_reason: MatchFailedReason | None = None
"""Reason for failed match when is_match = False."""
states: list[State] = field(default_factory=list)
"""List of matched entity states."""
no_match_name: str | None = None
"""Name of invalid area/floor or duplicate name when match fails for those reasons."""
areas: list[ar.AreaEntry] = field(default_factory=list)
"""Areas that were targeted."""
floors: list[fr.FloorEntry] = field(default_factory=list)
"""Floors that were targeted."""
@dataclass
class MatchTargetsConstraints:
"""Constraints for async_match_targets."""
@@ -361,6 +292,75 @@ class MatchTargetsPreferences:
"""Id of floor to use when deduplicating names."""
@dataclass
class MatchTargetsResult:
"""Result from async_match_targets."""
is_match: bool
"""True if one or more entities matched."""
no_match_reason: MatchFailedReason | None = None
"""Reason for failed match when is_match = False."""
states: list[State] = field(default_factory=list)
"""List of matched entity states."""
no_match_name: str | None = None
"""Name of invalid area/floor or duplicate name when match fails for those reasons."""
areas: list[ar.AreaEntry] = field(default_factory=list)
"""Areas that were targeted."""
floors: list[fr.FloorEntry] = field(default_factory=list)
"""Floors that were targeted."""
class MatchFailedError(IntentError):
"""Error when target matching fails."""
def __init__(
self,
result: MatchTargetsResult,
constraints: MatchTargetsConstraints,
preferences: MatchTargetsPreferences | None = None,
) -> None:
"""Initialize error."""
super().__init__()
self.result = result
self.constraints = constraints
self.preferences = preferences
def __str__(self) -> str:
"""Return string representation."""
return f"<MatchFailedError result={self.result}, constraints={self.constraints}, preferences={self.preferences}>"
class NoStatesMatchedError(MatchFailedError):
"""Error when no states match the intent's constraints."""
def __init__(
self,
reason: MatchFailedReason,
name: str | None = None,
area: str | None = None,
floor: str | None = None,
domains: set[str] | None = None,
device_classes: set[str] | None = None,
) -> None:
"""Initialize error."""
super().__init__(
result=MatchTargetsResult(False, reason),
constraints=MatchTargetsConstraints(
name=name,
area_name=area,
floor_name=floor,
domains=domains,
device_classes=device_classes,
),
)
@dataclass
class MatchTargetsCandidate:
"""Candidate for async_match_targets."""
@@ -915,7 +915,7 @@ class DynamicServiceIntentHandler(IntentHandler):
def __init__(
self,
intent_type: str,
*,
speech: str | None = None,
required_slots: _IntentSlotsType | None = None,
optional_slots: _IntentSlotsType | None = None,
required_domains: set[str] | None = None,
@@ -927,6 +927,7 @@ class DynamicServiceIntentHandler(IntentHandler):
) -> None:
"""Create Service Intent Handler."""
self.intent_type = intent_type
self.speech = speech
self.required_domains = required_domains
self.required_features = required_features
self.required_states = required_states
@@ -1113,6 +1114,7 @@ class DynamicServiceIntentHandler(IntentHandler):
)
for floor in match_result.floors
)
speech_name = match_result.floors[0].name
elif match_result.areas:
success_results.extend(
IntentResponseTarget(
@@ -1120,6 +1122,9 @@ class DynamicServiceIntentHandler(IntentHandler):
)
for area in match_result.areas
)
speech_name = match_result.areas[0].name
else:
speech_name = states[0].name
service_coros: list[Coroutine[Any, Any, None]] = []
for state in states:
@@ -1161,6 +1166,9 @@ class DynamicServiceIntentHandler(IntentHandler):
states = [hass.states.get(state.entity_id) or state for state in states]
response.async_set_states(states)
if self.speech is not None:
response.async_set_speech(self.speech.format(speech_name))
return response
async def async_call_service(
@@ -1223,7 +1231,7 @@ class ServiceIntentHandler(DynamicServiceIntentHandler):
intent_type: str,
domain: str,
service: str,
*,
speech: str | None = None,
required_slots: _IntentSlotsType | None = None,
optional_slots: _IntentSlotsType | None = None,
required_domains: set[str] | None = None,
@@ -1236,6 +1244,7 @@ class ServiceIntentHandler(DynamicServiceIntentHandler):
"""Create service handler."""
super().__init__(
intent_type,
speech=speech,
required_slots=required_slots,
optional_slots=optional_slots,
required_domains=required_domains,

View File

@@ -4,7 +4,6 @@ aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
aiodns==4.0.0
aiogithubapi==26.0.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
aiohttp==3.13.3
@@ -33,10 +32,10 @@ cronsim==2.7
cryptography==46.0.5
dbus-fast==3.1.2
file-read-backwards==2.0.0
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.10.2
habluetooth==5.9.1
hass-nabucasa==2.0.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
@@ -69,7 +68,7 @@ SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.0.2
ulid-transform==1.5.2
urllib3>=2.0
uv==0.10.6
voluptuous-openapi==0.2.0

10
mypy.ini generated
View File

@@ -5458,16 +5458,6 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.trmnl.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.tts.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@@ -28,10 +28,6 @@ dependencies = [
# module level in `bootstrap.py` and its requirements thus need to be in
# requirements.txt to ensure they are always installed
"aiogithubapi==26.0.0",
# Integrations may depend on hassio integration without listing it to
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
"aiohasupervisor==0.3.3",
"aiohttp==3.13.3",
"aiohttp_cors==0.8.1",
"aiohttp-fast-zlib==0.3.0",
@@ -48,7 +44,7 @@ dependencies = [
"certifi>=2021.5.30",
"ciso8601==2.3.3",
"cronsim==2.7",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==2.0.0",
@@ -76,7 +72,7 @@ dependencies = [
"standard-aifc==3.13.0",
"standard-telnetlib==3.13.0",
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.0.2",
"ulid-transform==1.5.2",
"urllib3>=2.0",
"uv==0.10.6",
"voluptuous==0.15.2",

5
requirements.txt generated
View File

@@ -5,7 +5,6 @@
# Home Assistant Core
aiodns==4.0.0
aiogithubapi==26.0.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
aiohttp==3.13.3
@@ -23,7 +22,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==46.0.5
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
ha-ffmpeg==3.2.2
hass-nabucasa==2.0.0
hassil==3.5.0
@@ -53,7 +52,7 @@ SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.0.2
ulid-transform==1.5.2
urllib3>=2.0
uv==0.10.6
voluptuous-openapi==0.2.0

17
requirements_all.txt generated
View File

@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.0.1
aioamazondevices==13.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -321,7 +321,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
aiomealie==1.2.2
aiomealie==1.2.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -997,7 +997,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -1173,7 +1173,7 @@ ha-silabs-firmware-client==0.3.0
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.10.2
habluetooth==5.9.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1883,7 +1883,7 @@ py-sucks==0.9.11
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.1.0
py-unifi-access==1.0.0
# homeassistant.components.atome
pyAtome==0.1.1
@@ -2636,7 +2636,7 @@ python-overseerr==0.9.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.5
python-pooldose==0.8.2
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2730,7 +2730,7 @@ pyvlx==0.2.30
pyvolumio==0.1.5
# homeassistant.components.waze_travel_time
pywaze==1.2.0
pywaze==1.1.1
# homeassistant.components.weatherflow
pyweatherflowudp==1.5.0
@@ -3129,9 +3129,6 @@ transmission-rpc==7.0.3
# homeassistant.components.triggercmd
triggercmd==0.0.36
# homeassistant.components.trmnl
trmnl==0.1.1
# homeassistant.components.twinkly
ttls==1.8.3

View File

@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.0.1
aioamazondevices==13.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -306,7 +306,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
aiomealie==1.2.2
aiomealie==1.2.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -882,7 +882,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -1043,7 +1043,7 @@ ha-silabs-firmware-client==0.3.0
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.10.2
habluetooth==5.9.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1632,7 +1632,7 @@ py-sucks==0.9.11
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.1.0
py-unifi-access==1.0.0
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -2235,7 +2235,7 @@ python-overseerr==0.9.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.5
python-pooldose==0.8.2
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2314,7 +2314,7 @@ pyvlx==0.2.30
pyvolumio==0.1.5
# homeassistant.components.waze_travel_time
pywaze==1.2.0
pywaze==1.1.1
# homeassistant.components.weatherflow
pyweatherflowudp==1.5.0
@@ -2632,9 +2632,6 @@ transmission-rpc==7.0.3
# homeassistant.components.triggercmd
triggercmd==0.0.36
# homeassistant.components.trmnl
trmnl==0.1.1
# homeassistant.components.twinkly
ttls==1.8.3

View File

@@ -2,7 +2,10 @@
from unittest.mock import AsyncMock
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.devices import (
SPEAKER_GROUP_DEVICE_TYPE,
SPEAKER_GROUP_FAMILY,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
import pytest
@@ -114,7 +117,7 @@ async def test_alexa_dnd_group_removal(
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="Amazon",
model="Speaker Group",
model=SPEAKER_GROUP_DEVICE_TYPE,
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -153,7 +156,7 @@ async def test_alexa_unsupported_notification_sensor_removal(
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="Amazon",
model="Speaker Group",
model=SPEAKER_GROUP_DEVICE_TYPE,
entry_type=dr.DeviceEntryType.SERVICE,
)

View File

@@ -1,99 +0,0 @@
# serializer version: 1
# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Incoming video interlaced',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Incoming video interlaced',
'platform': 'arcam_fmj',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'incoming_video_interlaced',
'unique_id': '456789abcdef-1-incoming_video_interlaced',
'unit_of_measurement': None,
})
# ---
# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Arcam FMJ (127.0.0.1) Incoming video interlaced',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Incoming video interlaced',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Incoming video interlaced',
'platform': 'arcam_fmj',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'incoming_video_interlaced',
'unique_id': '456789abcdef-2-incoming_video_interlaced',
'unit_of_measurement': None,
})
# ---
# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Arcam FMJ (127.0.0.1) Zone 2 Incoming video interlaced',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +0,0 @@
"""Tests for Arcam FMJ binary sensor entities."""
from collections.abc import Generator
from unittest.mock import Mock, patch
from arcam.fmj.state import State
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def binary_sensor_only() -> Generator[None]:
"""Limit platform setup to binary_sensor only."""
with patch(
"homeassistant.components.arcam_fmj.PLATFORMS", [Platform.BINARY_SENSOR]
):
yield
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "player_setup")
async def test_setup(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test snapshot of the binary sensor platform."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("player_setup")
async def test_binary_sensor_none(
hass: HomeAssistant,
) -> None:
"""Test binary sensor when video parameters are None."""
state = hass.states.get(
"binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced"
)
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("player_setup")
async def test_binary_sensor_interlaced(
hass: HomeAssistant,
state_1: State,
client: Mock,
) -> None:
"""Test binary sensor reports on when video is interlaced."""
video_params = Mock()
video_params.interlaced = True
state_1.get_incoming_video_parameters.return_value = video_params
client.notify_data_updated()
await hass.async_block_till_done()
state = hass.states.get(
"binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced"
)
assert state is not None
assert state.state == STATE_ON
@pytest.mark.usefixtures("player_setup")
async def test_binary_sensor_not_interlaced(
hass: HomeAssistant,
state_1: State,
client: Mock,
) -> None:
"""Test binary sensor reports off when video is not interlaced."""
video_params = Mock()
video_params.interlaced = False
state_1.get_incoming_video_parameters.return_value = video_params
client.notify_data_updated()
await hass.async_block_till_done()
state = hass.states.get(
"binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced"
)
assert state is not None
assert state.state == STATE_OFF

View File

@@ -1,94 +0,0 @@
"""Tests for Arcam FMJ sensor entities."""
from collections.abc import Generator
from unittest.mock import Mock, patch
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def sensor_only() -> Generator[None]:
"""Limit platform setup to sensor only."""
with patch("homeassistant.components.arcam_fmj.PLATFORMS", [Platform.SENSOR]):
yield
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "player_setup")
async def test_setup(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test snapshot of the sensor platform."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("player_setup")
async def test_sensor_video_parameters(
hass: HomeAssistant,
state_1: State,
client: Mock,
) -> None:
"""Test video parameter sensors with actual data."""
video_params = Mock()
video_params.horizontal_resolution = 1920
video_params.vertical_resolution = 1080
video_params.refresh_rate = 60.0
video_params.aspect_ratio = IncomingVideoAspectRatio.ASPECT_16_9
video_params.colorspace = IncomingVideoColorspace.HDR10
state_1.get_incoming_video_parameters.return_value = video_params
client.notify_data_updated()
await hass.async_block_till_done()
expected = {
"incoming_video_horizontal_resolution": "1920",
"incoming_video_vertical_resolution": "1080",
"incoming_video_refresh_rate": "60.0",
"incoming_video_aspect_ratio": "aspect_16_9",
"incoming_video_colorspace": "hdr10",
}
for key, value in expected.items():
state = hass.states.get(f"sensor.arcam_fmj_127_0_0_1_{key}")
assert state is not None, f"State missing for {key}"
assert state.state == value, f"Expected {value} for {key}, got {state.state}"
@pytest.mark.usefixtures("player_setup")
async def test_sensor_audio_parameters(
hass: HomeAssistant,
state_1: State,
client: Mock,
) -> None:
"""Test audio parameter sensors with actual data."""
state_1.get_incoming_audio_format.return_value = (
IncomingAudioFormat.PCM,
IncomingAudioConfig.STEREO_ONLY,
)
state_1.get_incoming_audio_sample_rate.return_value = 48000
client.notify_data_updated()
await hass.async_block_till_done()
assert (
hass.states.get("sensor.arcam_fmj_127_0_0_1_incoming_audio_format").state
== "pcm"
)
assert (
hass.states.get("sensor.arcam_fmj_127_0_0_1_incoming_audio_configuration").state
== "stereo_only"
)
assert (
hass.states.get("sensor.arcam_fmj_127_0_0_1_incoming_audio_sample_rate").state
== "48000"
)

View File

@@ -1,59 +0,0 @@
# serializer version: 1
# name: test_diagnostics
dict({
'player': dict({
'_country': None,
'avatar': 'https://images.chesscomfiles.com/uploads/v1/user/532748851.d5fefa92.200x200o.da2274e46acd.jpg',
'country_url': 'https://api.chess.com/pub/country/NL',
'fide': None,
'followers': 2,
'is_streamer': False,
'joined': '2026-02-20T10:48:14',
'last_online': '2026-03-06T12:32:59',
'location': 'Utrecht',
'name': 'Joost',
'player_id': 532748851,
'status': 'basic',
'title': None,
'twitch_url': None,
'username': 'joostlek',
}),
'stats': dict({
'chess960_daily': None,
'chess_blitz': None,
'chess_bullet': None,
'chess_daily': dict({
'last': dict({
'date': 1772800350,
'rating': 495,
'rd': 196,
}),
'record': dict({
'draw': 0,
'loss': 4,
'time_per_move': 6974,
'timeout_percent': 0,
'win': 0,
}),
}),
'chess_rapid': None,
'lessons': None,
'puzzle_rush': dict({
'best': dict({
'score': 8,
'total_attempts': 11,
}),
}),
'tactics': dict({
'highest': dict({
'date': 1772782351,
'rating': 764,
}),
'lowest': dict({
'date': 1771584762,
'rating': 400,
}),
}),
}),
})
# ---

View File

@@ -1,29 +0,0 @@
"""Tests for the Chess.com diagnostics."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_chess_client: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -43,7 +43,7 @@ async def test_open_cover_intent(hass: HomeAssistant, slots: dict[str, Any]) ->
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert response.speech["plain"]["speech"] == "Opening garage door"
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
@@ -75,7 +75,7 @@ async def test_close_cover_intent(hass: HomeAssistant, slots: dict[str, Any]) ->
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert response.speech["plain"]["speech"] == "Closing garage door"
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN

View File

@@ -165,26 +165,3 @@ async def test_on_link_failed(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_zeroconf_missing_api_domain(
hass: HomeAssistant,
) -> None:
"""Test zeroconf flow aborts if api_domain is missing from properties."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.254"),
ip_addresses=[ip_address("192.168.1.254")],
port=80,
hostname="Freebox-Server.local.",
type="_fbx-api._tcp.local.",
name="Freebox Server._fbx-api._tcp.local.",
properties={"api_version": "8.0"}, # api_domain intentionally omitted
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_api_domain"

View File

@@ -1,15 +1,13 @@
"""Common stuff for Fritz!Tools tests."""
from __future__ import annotations
from collections.abc import Generator
from copy import deepcopy
import logging
from typing import Any
from unittest.mock import MagicMock, patch
from fritzconnection.core.processor import Service
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
from fritzconnection.lib.fritztools import ArgumentNamespace
import pytest
from homeassistant.components.fritz.coordinator import FritzConnectionCached
@@ -19,101 +17,83 @@ from .const import (
MOCK_HOST_ATTRIBUTES_DATA,
MOCK_MESH_DATA,
MOCK_MODELNAME,
MOCK_STATUS_AVM_DEVICE_LOG_DATA,
MOCK_STATUS_CONNECTION_DATA,
MOCK_STATUS_DEVICE_INFO_DATA,
)
LOGGER = logging.getLogger(__name__)
class FritzServiceMock:
class FritzServiceMock(Service):
"""Service mocking."""
def __init__(self, actions: list[str]) -> None:
def __init__(self, serviceId: str, actions: dict) -> None:
"""Init Service mock."""
self.actions = actions
super().__init__()
self._actions = actions
self.serviceId = serviceId
class FritzConnectionMock:
"""FritzConnection mocking."""
def __init__(self, fc_data: dict[str, dict[str, Any]]) -> None:
def __init__(self, services) -> None:
"""Init Mocking class."""
self._fc_data: dict[str, dict[str, Any]]
self.services: dict[str, FritzServiceMock]
self._call_cache: dict[str, dict[str, Any]] = {}
self.modelname = MOCK_MODELNAME
self._side_effect: Exception | None = None
self._service_normalization(fc_data)
self.call_action = self._call_action
self._services = services
self.services = {
srv: FritzServiceMock(serviceId=srv, actions=actions)
for srv, actions in services.items()
}
LOGGER.debug("-" * 80)
LOGGER.debug("FritzConnectionMock - services: %s", self.services)
def _service_normalization(self, fc_data: dict[str, dict[str, Any]]) -> None:
"""Normalize service name."""
self._fc_data = deepcopy(fc_data)
self.services = {
service.replace(":", ""): FritzServiceMock(list(actions.keys()))
for service, actions in fc_data.items()
}
def call_action_side_effect(self, side_effect: Exception | None) -> None:
def call_action_side_effect(self, side_effect=None) -> None:
"""Set or unset a side_effect for call_action."""
self._side_effect = side_effect
if side_effect is not None:
self.call_action = MagicMock(side_effect=side_effect)
else:
self.call_action = self._call_action
def override_services(self, fc_data: dict[str, dict[str, Any]]) -> None:
"""Override services data."""
self._service_normalization(fc_data)
def override_services(self, services) -> None:
"""Overrire services data."""
self._services = services
def clear_cache(self) -> None:
"""Mock clear_cache method."""
return FritzConnectionCached.clear_cache(self)
def call_action(self, service: str, action: str, **kwargs: Any) -> Any:
"""Simulate TR-064 call with service name normalization."""
def _call_action(self, service: str, action: str, **kwargs):
LOGGER.debug(
"_call_action service: %s, action: %s, **kwargs: %s",
service,
action,
{**kwargs},
)
if self._side_effect:
raise self._side_effect
if ":" in service:
service, number = service.split(":", 1)
service = service + number
elif not service[-1].isnumeric():
service = service + "1"
normalized = service
if service not in self._fc_data:
# tolerate DeviceInfo1 <-> DeviceInfo:1 and similar
if (
(":" in service and (alt := service.replace(":", "")) in self._fc_data)
or (alt := f"{service}1") in self._fc_data
or (alt := f"{service}:1") in self._fc_data
or (
service.endswith("1")
and ":" not in service
and (alt := f"{service[:-1]}:1") in self._fc_data
)
):
normalized = alt
action_data = self._fc_data.get(normalized, {}).get(action, {})
if kwargs:
if (index := kwargs.get("NewIndex")) is None:
index = next(iter(kwargs.values()))
if isinstance(action_data, dict) and index in action_data:
return action_data[index]
return action_data
return self._services[service][action][index]
return self._services[service][action]
@pytest.fixture(name="fc_data")
def fc_data_mock() -> dict[str, dict[str, Any]]:
def fc_data_mock() -> dict[str, dict]:
"""Fixture for default fc_data."""
return deepcopy(MOCK_FB_SERVICES)
return MOCK_FB_SERVICES
@pytest.fixture
def fc_class_mock(fc_data: dict[str, dict[str, Any]]) -> Generator[MagicMock]:
def fc_class_mock(fc_data: dict[str, dict]) -> Generator[FritzConnectionMock]:
"""Fixture that sets up a mocked FritzConnection class."""
with patch(
"homeassistant.components.fritz.coordinator.FritzConnectionCached",
@@ -158,10 +138,20 @@ def fs_class_mock() -> Generator[type[FritzStatus]]:
"get_default_connection_service",
MagicMock(return_value=MOCK_STATUS_CONNECTION_DATA),
),
patch.object(
FritzStatus,
"get_device_info",
MagicMock(return_value=ArgumentNamespace(MOCK_STATUS_DEVICE_INFO_DATA)),
),
patch.object(FritzStatus, "get_monitor_data", MagicMock(return_value={})),
patch.object(
FritzStatus, "get_cpu_temperatures", MagicMock(return_value=[42, 38])
),
patch.object(
FritzStatus,
"get_avm_device_log",
MagicMock(return_value=MOCK_STATUS_AVM_DEVICE_LOG_DATA),
),
patch.object(FritzStatus, "has_wan_enabled", True),
):
yield result

View File

@@ -1,7 +1,5 @@
"""Common stuff for Fritz!Tools tests."""
from typing import Any
from fritzconnection.lib.fritzstatus import DefaultConnectionService
from homeassistant.components.fritz.const import DOMAIN
@@ -56,7 +54,7 @@ MOCK_MESH_MASTER_WIFI1_MAC = "1C:ED:6F:12:34:12"
MOCK_MESH_SLAVE_MAC = "1C:ED:6F:12:34:21"
MOCK_MESH_SLAVE_WIFI1_MAC = "1C:ED:6F:12:34:22"
MOCK_FB_SERVICES: dict[str, dict[str, Any]] = {
MOCK_FB_SERVICES: dict[str, dict] = {
"DeviceInfo1": {
"GetInfo": {
"NewSerialNumber": MOCK_MESH_MASTER_MAC,

View File

@@ -2,16 +2,9 @@
from __future__ import annotations
from collections.abc import Generator
from copy import deepcopy
from typing import cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from unittest.mock import MagicMock, patch
from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
FritzSecurityError,
)
from fritzconnection.lib.fritztools import ArgumentNamespace
import pytest
@@ -21,12 +14,7 @@ from homeassistant.components.fritz.const import (
DEFAULT_SSL,
DOMAIN,
)
from homeassistant.components.fritz.coordinator import (
AvmWrapper,
ClassSetupMissing,
FritzBoxTools,
FritzConnectionCached,
)
from homeassistant.components.fritz.coordinator import AvmWrapper, ClassSetupMissing
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_HOST,
@@ -36,58 +24,13 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from .conftest import FritzConnectionMock, FritzServiceMock
from .const import MOCK_MESH_MASTER_MAC, MOCK_STATUS_DEVICE_INFO_DATA, MOCK_USER_DATA
from tests.common import MockConfigEntry
@pytest.fixture(name="mock_config_entry")
def fixture_mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry with host, username, password, and port."""
return MockConfigEntry(
domain=DOMAIN,
data=MOCK_USER_DATA,
unique_id="1234",
)
@pytest.fixture
def patch_fritzconnectioncached_globally(fc_data) -> Generator[FritzConnectionMock]:
"""Patch FritzConnectionCached globally for coordinator-only tests."""
mock_conn = FritzConnectionMock(fc_data)
with patch(
"homeassistant.components.fritz.coordinator.FritzConnectionCached",
return_value=mock_conn,
):
yield mock_conn
@pytest.fixture(name="fritz_tools")
async def fixture_fritz_tools(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
patch_fritzconnectioncached_globally: FritzConnectionMock,
) -> FritzBoxTools:
"""Return FritzBoxTools instance with mocked connection."""
mock_config_entry.add_to_hass(hass)
coordinator = FritzBoxTools(
hass=hass,
config_entry=mock_config_entry,
password=mock_config_entry.data["password"],
port=mock_config_entry.data["port"],
)
await coordinator.async_setup()
return coordinator
@pytest.mark.parametrize(
"attr",
[
@@ -184,13 +127,12 @@ async def test_no_software_version(
device_info = deepcopy(MOCK_STATUS_DEVICE_INFO_DATA)
device_info["NewSoftwareVersion"] = "string_version_not_number"
with patch.object(
fs_class_mock,
"get_device_info",
MagicMock(return_value=ArgumentNamespace(device_info)),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
fs_class_mock.get_device_info = MagicMock(
return_value=ArgumentNamespace(device_info)
)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.state is ConfigEntryState.LOADED
@@ -199,371 +141,3 @@ async def test_no_software_version(
)
assert device
assert device.sw_version == "string_version_not_number"
async def test_connection_cached_call_action() -> None:
"""Test call_action cache behavior for get and non-get actions."""
conn = object.__new__(FritzConnectionCached)
with patch(
"homeassistant.components.fritz.coordinator.FritzConnection.call_action",
autospec=True,
return_value={"ok": True},
) as parent_call:
first = conn.call_action("Svc", "GetInfo", arguments={"a": 1})
second = conn.call_action("Svc", "GetInfo", arguments={"a": 1})
assert first == {"ok": True}
assert second == first
assert parent_call.call_count == 1
conn.clear_cache()
third = conn.call_action("Svc", "GetInfo", arguments={"a": 1})
assert third == first
assert parent_call.call_count == 2
conn.call_action("Svc", "SetEnable", NewEnable="1")
assert parent_call.call_count == 3
async def test_async_get_wan_access_error_returns_none(
fritz_tools,
) -> None:
"""Test WAN access query error handling returns None."""
cast(FritzConnectionMock, fritz_tools.connection).call_action_side_effect(
FritzActionError("boom")
)
assert await fritz_tools._async_get_wan_access("192.168.1.2") is None
async def test_async_get_wan_access_success(
fritz_tools,
) -> None:
"""Test WAN access query success path."""
fritz_tools.connection.call_action = MagicMock(return_value={"NewDisallow": False})
assert await fritz_tools._async_get_wan_access("192.168.1.2") is True
async def test_async_update_hosts_info_attributes_branches(
fritz_tools,
) -> None:
"""Test host-attributes branch."""
fritz_tools.fritz_hosts.get_hosts_attributes = MagicMock(
return_value=[
{
"HostName": "printer",
"Active": True,
"IPAddress": "192.168.178.2",
"MACAddress": "AA:BB:CC:DD:EE:01",
"X_AVM-DE_WANAccess": "granted",
},
{
"HostName": "server",
"Active": False,
"IPAddress": "192.168.178.3",
"MACAddress": "AA:BB:CC:DD:EE:02",
},
{
"HostName": "ignored",
"Active": False,
"IPAddress": "192.168.178.4",
"MACAddress": "",
},
]
)
hosts = await fritz_tools._async_update_hosts_info()
assert set(hosts) == {"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"}
assert hosts["AA:BB:CC:DD:EE:01"].wan_access is True
assert hosts["AA:BB:CC:DD:EE:02"].wan_access is None
async def test_async_update_hosts_info_hosts_info_fallback(
fritz_tools,
) -> None:
"""Test hosts-info fallback branch after attribute action error."""
fritz_tools.fritz_hosts.get_hosts_attributes = MagicMock(
side_effect=FritzActionError("not supported")
)
fritz_tools.fritz_hosts.get_hosts_info = MagicMock(
return_value=[
{"name": "printer", "status": True, "ip": "192.168.178.2", "mac": "AA:BB"},
{"name": "server", "status": False, "ip": "", "mac": "AA:CC"},
{"name": "ignore", "status": False, "ip": "192.168.178.10", "mac": ""},
]
)
with patch.object(
fritz_tools,
"_async_get_wan_access",
new=AsyncMock(return_value=False),
) as wan_access:
hosts = await fritz_tools._async_update_hosts_info()
assert set(hosts) == {"AA:BB", "AA:CC"}
assert hosts["AA:BB"].wan_access is False
assert hosts["AA:CC"].wan_access is None
wan_access.assert_awaited_once_with("192.168.178.2")
async def test_async_update_hosts_info_raises_homeassistant_error(
fritz_tools,
) -> None:
"""Test host update raises HomeAssistantError when API calls fail."""
fritz_tools.fritz_hosts.get_hosts_attributes = MagicMock(
side_effect=FritzActionError("not supported")
)
fritz_tools.fritz_hosts.get_hosts_info = MagicMock(
side_effect=RuntimeError("broken")
)
with pytest.raises(HomeAssistantError) as exc_info:
await fritz_tools._async_update_hosts_info()
assert exc_info.value.translation_key == "error_refresh_hosts_info"
async def test_async_update_call_deflections_empty_paths(
fritz_tools,
) -> None:
"""Test call deflections empty responses."""
fritz_tools.connection.call_action = MagicMock(return_value={})
assert await fritz_tools.async_update_call_deflections() == {}
fritz_tools.connection.call_action = MagicMock(
return_value={"NewDeflectionList": "<List><Foo>Bar</Foo></List>"}
)
assert await fritz_tools.async_update_call_deflections() == {}
async def test_async_scan_devices_stopping_returns(
hass: HomeAssistant,
fritz_tools,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test scan devices exits when Home Assistant is stopping."""
with patch.object(hass, "is_stopping", True):
await fritz_tools.async_scan_devices()
assert "Cannot execute scan devices: HomeAssistant is shutting down" in caplog.text
async def test_async_scan_devices_old_discovery_branch(
fritz_tools,
) -> None:
"""Test old discovery path when mesh support is unavailable."""
hosts = {"AA:BB": MagicMock()}
with (
patch.object(
type(fritz_tools.fritz_status),
"device_has_mesh_support",
new_callable=PropertyMock,
return_value=False,
),
patch.object(
fritz_tools, "_async_update_hosts_info", AsyncMock(return_value=hosts)
),
patch.object(fritz_tools, "manage_device_info", return_value=True),
patch.object(
fritz_tools, "async_send_signal_device_update", AsyncMock()
) as update,
):
await fritz_tools.async_scan_devices()
update.assert_awaited_once_with(True)
async def test_async_scan_devices_empty_mesh_topology_raises(
fritz_tools,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test empty mesh topology raises as expected."""
with (
patch.object(
type(fritz_tools.fritz_status),
"device_has_mesh_support",
new_callable=PropertyMock,
return_value=True,
),
patch.object(
fritz_tools, "_async_update_hosts_info", AsyncMock(return_value={})
),
patch.object(
fritz_tools.fritz_hosts, "get_mesh_topology", MagicMock(return_value={})
),
pytest.raises(Exception, match="Mesh supported but empty topology reported"),
):
await fritz_tools.async_scan_devices()
assert "ERROR" not in caplog.text
async def test_async_scan_devices_mesh_guest_and_missing_host(
fritz_tools,
) -> None:
"""Test mesh client processing for AP guest and unknown hosts."""
hosts = {"AA:BB:CC:DD:EE:01": MagicMock(wan_access=True)}
topology = {
"nodes": [
{
"is_meshed": True,
"mesh_role": "master",
"device_name": "fritz.box",
"node_interfaces": [
{
"uid": "ap-guest",
"mac_address": fritz_tools.unique_id,
"op_mode": "AP_GUEST",
"ssid": "guest",
"type": "WLAN",
"name": "uplink0",
"node_links": [],
}
],
},
{
"is_meshed": False,
"node_interfaces": [
{
"mac_address": "AA:BB:CC:DD:EE:02",
"node_links": [
{"state": "CONNECTED", "node_interface_1_uid": "ap-guest"}
],
},
{
"mac_address": "AA:BB:CC:DD:EE:01",
"node_links": [
{"state": "CONNECTED", "node_interface_1_uid": "ap-guest"}
],
},
],
},
]
}
with (
patch.object(
fritz_tools, "_async_update_hosts_info", AsyncMock(return_value=hosts)
),
patch.object(
fritz_tools.fritz_hosts,
"get_mesh_topology",
MagicMock(return_value=topology),
),
patch.object(fritz_tools, "manage_device_info", return_value=False) as manage,
patch.object(fritz_tools, "async_send_signal_device_update", AsyncMock()),
):
await fritz_tools.async_scan_devices()
dev_info = manage.call_args.args[0]
assert dev_info.wan_access is None
async def test_trigger_methods(
fritz_tools,
) -> None:
"""Test trigger methods delegate to correct underlying calls."""
fritz_tools.connection.call_action = MagicMock(
return_value={"NewX_AVM-DE_UpdateState": True}
)
fritz_tools.connection.reboot = MagicMock()
fritz_tools.connection.reconnect = MagicMock()
fritz_tools.fritz_guest_wifi.set_password = MagicMock()
fritz_tools.fritz_call.dial = MagicMock()
fritz_tools.fritz_call.hangup = MagicMock()
assert await fritz_tools.async_trigger_firmware_update() is True
await fritz_tools.async_trigger_reboot()
await fritz_tools.async_trigger_reconnect()
await fritz_tools.async_trigger_set_guest_password("new-password", 20)
with patch(
"homeassistant.components.fritz.coordinator.asyncio.sleep",
new=AsyncMock(),
) as sleep_mock:
await fritz_tools.async_trigger_dial("012345", 1)
fritz_tools.connection.reboot.assert_called_once()
fritz_tools.connection.reconnect.assert_called_once()
fritz_tools.fritz_guest_wifi.set_password.assert_called_once_with(
"new-password", 20
)
fritz_tools.fritz_call.dial.assert_called_once_with("012345")
sleep_mock.assert_awaited_once_with(1)
fritz_tools.fritz_call.hangup.assert_called_once()
async def test_avmwrapper_service_call_branches(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
fc_class_mock,
fh_class_mock,
fs_class_mock,
) -> None:
"""Test AvmWrapper service call return and exception branches."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
wrapper = entry.runtime_data
wrapper.connection.services.pop("Hosts1", None)
assert await wrapper._async_service_call("Hosts", "1", "GetInfo") == {}
wrapper.connection.services["Hosts1"] = FritzServiceMock(["GetInfo"])
wrapper.connection.call_action = MagicMock(side_effect=FritzSecurityError("boom"))
assert await wrapper._async_service_call("Hosts", "1", "GetInfo") == {}
wrapper.connection.call_action = MagicMock(side_effect=FritzActionError("boom"))
assert await wrapper._async_service_call("Hosts", "1", "GetInfo") == {}
with patch.object(
hass,
"async_add_executor_job",
new=AsyncMock(side_effect=FritzConnectionException("boom")),
):
assert await wrapper._async_service_call("Hosts", "1", "GetInfo") == {}
assert "cannot execute service Hosts with action GetInfo" in caplog.text
async def test_avmwrapper_passthrough_methods(
hass: HomeAssistant,
fc_class_mock,
fh_class_mock,
fs_class_mock,
) -> None:
"""Test AvmWrapper helper methods and service wrappers."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
wrapper = entry.runtime_data
wrapper.device_is_router = False
assert await wrapper.async_ipv6_active() is False
assert await wrapper.async_set_wlan_configuration(1, True) == {}
assert await wrapper.async_set_deflection_enable(1, False) == {}
assert (
await wrapper.async_add_port_mapping(
"WANPPPConnection", {"NewExternalPort": 8080}
)
== {}
)
assert await wrapper.async_set_allow_wan_access("192.168.178.2", True) == {}
assert await wrapper.async_wake_on_lan("AA:BB:CC:DD:EE:FF") == {}

View File

@@ -547,7 +547,9 @@
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -584,6 +586,7 @@
'attribution': 'Data provided by unpublished Intellifire API',
'device_class': 'timestamp',
'friendly_name': 'IntelliFire Timer end',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.intellifire_timer_end',

View File

@@ -248,11 +248,12 @@ async def test_cover_intents_loading(hass: HomeAssistant) -> None:
hass.states.async_set("cover.garage_door", "closed")
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
await intent.async_handle(
response = await intent.async_handle(
hass, "test", "HassOpenCover", {"name": {"value": "garage door"}}
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Opening garage door"
assert len(calls) == 1
call = calls[0]
assert call.domain == "cover"

View File

@@ -3,8 +3,192 @@
from homeassistant.components.litterrobot import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
BASE_PATH = "homeassistant.components.litterrobot"
CONFIG = {DOMAIN: {CONF_USERNAME: "user@example.com", CONF_PASSWORD: "password"}}
ACCOUNT_USER_ID = "1234567"
ROBOT_NAME = "Test"
ROBOT_SERIAL = "LR3C012345"
ROBOT_DATA = {
"powerStatus": "AC",
"lastSeen": "2022-09-17T13:06:37.884Z",
"cleanCycleWaitTimeMinutes": "7",
"unitStatus": "RDY",
"litterRobotNickname": ROBOT_NAME,
"cycleCount": "15",
"panelLockActive": "0",
"cyclesAfterDrawerFull": "0",
"litterRobotSerial": ROBOT_SERIAL,
"cycleCapacity": "30",
"litterRobotId": "a0123b4567cd8e",
"nightLightActive": "1",
"sleepModeActive": "112:50:19",
}
ROBOT_4_DATA = {
"name": ROBOT_NAME,
"serial": "LR4C010001",
"userId": "1234567",
"espFirmware": "1.1.50",
"picFirmwareVersion": "10512.2560.2.53",
"laserBoardFirmwareVersion": "4.0.65.4",
"wifiRssi": -53.0,
"unitPowerType": "AC",
"catWeight": 12.0,
"displayCode": "DC_MODE_IDLE",
"unitTimezone": "America/New_York",
"unitTime": None,
"cleanCycleWaitTime": 15,
"isKeypadLockout": False,
"nightLightMode": "OFF",
"nightLightBrightness": 50,
"isPanelSleepMode": False,
"panelBrightnessHigh": 50,
"panelSleepTime": 0,
"panelWakeTime": 0,
"weekdaySleepModeEnabled": {
"Sunday": {"sleepTime": 0, "wakeTime": 0, "isEnabled": False},
"Monday": {"sleepTime": 0, "wakeTime": 180, "isEnabled": True},
"Tuesday": {"sleepTime": 0, "wakeTime": 180, "isEnabled": True},
"Wednesday": {"sleepTime": 0, "wakeTime": 180, "isEnabled": True},
"Thursday": {"sleepTime": 0, "wakeTime": 180, "isEnabled": True},
"Friday": {"sleepTime": 0, "wakeTime": 180, "isEnabled": True},
"Saturday": {"sleepTime": 0, "wakeTime": 0, "isEnabled": False},
},
"unitPowerStatus": "ON",
"sleepStatus": "WAKE",
"robotStatus": "ROBOT_IDLE",
"globeMotorFaultStatus": "FAULT_CLEAR",
"pinchStatus": "CLEAR",
"catDetect": "CAT_DETECT_CLEAR",
"isBonnetRemoved": False,
"isNightLightLEDOn": False,
"odometerPowerCycles": 8,
"odometerCleanCycles": 158,
"odometerEmptyCycles": 1,
"odometerFilterCycles": 0,
"isDFIResetPending": False,
"DFINumberOfCycles": 104,
"DFILevelPercent": 76,
"isDFIFull": False,
"DFIFullCounter": 3,
"DFITriggerCount": 42,
"litterLevel": 460,
"DFILevelMM": 115,
"isCatDetectPending": False,
"globeMotorRetractFaultStatus": "FAULT_CLEAR",
"robotCycleStatus": "CYCLE_IDLE",
"robotCycleState": "CYCLE_STATE_WAIT_ON",
"weightSensor": -3.0,
"isOnline": True,
"isOnboarded": True,
"isProvisioned": True,
"isDebugModeActive": False,
"lastSeen": "2022-09-17T12:06:37.884Z",
"sessionId": "abcdef12-e358-4b6c-9022-012345678912",
"setupDateTime": "2022-08-28T17:01:12.644Z",
"isFirmwareUpdateTriggered": False,
"firmwareUpdateStatus": "NONE",
"wifiModeStatus": "ROUTER_CONNECTED",
"isUSBPowerOn": True,
"USBFaultStatus": "CLEAR",
"isDFIPartialFull": True,
"isLaserDirty": False,
"surfaceType": "TILE",
"hopperStatus": None,
"scoopsSavedCount": 3769,
"isHopperRemoved": None,
"optimalLitterLevel": 450,
"litterLevelPercentage": 0.7,
"litterLevelState": "OPTIMAL",
}
FEEDER_ROBOT_DATA = {
"id": 1,
"name": ROBOT_NAME,
"serial": "RF1C000001",
"timezone": "America/Denver",
"isEighthCupEnabled": False,
"created_at": "2021-12-15T06:45:00.000000+00:00",
"household_id": 1,
"state": {
"id": 1,
"info": {
"level": 2,
"power": True,
"online": True,
"acPower": True,
"dcPower": False,
"gravity": False,
"chuteFull": False,
"fwVersion": "1.0.0",
"onBoarded": True,
"unitMeals": 0,
"motorJammed": False,
"chuteFullExt": False,
"panelLockout": False,
"unitPortions": 0,
"autoNightMode": True,
"mealInsertSize": 1,
},
"updated_at": "2022-09-08T15:07:00.000000+00:00",
"active_schedule": {
"id": "1",
"name": "Feeding",
"meals": [
{
"id": "1",
"days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"hour": 6,
"name": "Breakfast",
"skip": None,
"minute": 30,
"paused": False,
"portions": 3,
"mealNumber": 1,
"scheduleId": None,
}
],
"created_at": "2021-12-17T07:07:31.047747+00:00",
},
},
"feeding_snack": [
{"timestamp": "2022-09-04T03:03:00.000000+00:00", "amount": 0.125},
{"timestamp": "2022-08-30T16:34:00.000000+00:00", "amount": 0.25},
],
"feeding_meal": [
{
"timestamp": "2022-09-08T18:00:00.000000+00:00",
"amount": 0.125,
"meal_name": "Lunch",
"meal_number": 2,
"meal_total_portions": 2,
},
{
"timestamp": "2022-09-08T12:00:00.000000+00:00",
"amount": 0.125,
"meal_name": "Breakfast",
"meal_number": 1,
"meal_total_portions": 1,
},
],
}
PET_DATA = {
"petId": "PET-123",
"userId": "1234567",
"createdAt": "2023-04-27T23:26:49.813Z",
"name": "Kitty",
"type": "CAT",
"gender": "FEMALE",
"lastWeightReading": 9.1,
"breeds": ["sphynx"],
"weightHistory": [
{"weight": 6.48, "timestamp": "2025-06-13T16:12:36"},
{"weight": 6.6, "timestamp": "2025-06-14T03:52:00"},
{"weight": 6.59, "timestamp": "2025-06-14T17:20:32"},
{"weight": 6.5, "timestamp": "2025-06-14T19:22:48"},
{"weight": 6.35, "timestamp": "2025-06-15T03:12:15"},
{"weight": 6.45, "timestamp": "2025-06-15T15:27:21"},
{"weight": 6.25, "timestamp": "2025-06-15T15:29:26"},
],
}
VACUUM_ENTITY_ID = "vacuum.test_litter_box"

View File

@@ -12,14 +12,17 @@ import pytest
from homeassistant.core import HomeAssistant
from .common import ACCOUNT_USER_ID, CONFIG, DOMAIN
from .common import (
ACCOUNT_USER_ID,
CONFIG,
DOMAIN,
FEEDER_ROBOT_DATA,
PET_DATA,
ROBOT_4_DATA,
ROBOT_DATA,
)
from tests.common import MockConfigEntry, load_json_object_fixture
ROBOT_DATA = load_json_object_fixture("litter_robot_3_data.json", DOMAIN)
ROBOT_4_DATA = load_json_object_fixture("litter_robot_4_data.json", DOMAIN)
FEEDER_ROBOT_DATA = load_json_object_fixture("feeder_robot_data.json", DOMAIN)
PET_DATA = load_json_object_fixture("pet_data.json", DOMAIN)
from tests.common import MockConfigEntry
def create_mock_robot(

View File

@@ -1,70 +0,0 @@
{
"id": 1,
"name": "Test",
"serial": "RF1C000001",
"timezone": "America/Denver",
"isEighthCupEnabled": false,
"created_at": "2021-12-15T06:45:00.000000+00:00",
"household_id": 1,
"state": {
"id": 1,
"info": {
"level": 2,
"power": true,
"online": true,
"acPower": true,
"dcPower": false,
"gravity": false,
"chuteFull": false,
"fwVersion": "1.0.0",
"onBoarded": true,
"unitMeals": 0,
"motorJammed": false,
"chuteFullExt": false,
"panelLockout": false,
"unitPortions": 0,
"autoNightMode": true,
"mealInsertSize": 1
},
"updated_at": "2022-09-08T15:07:00.000000+00:00",
"active_schedule": {
"id": "1",
"name": "Feeding",
"meals": [
{
"id": "1",
"days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"hour": 6,
"name": "Breakfast",
"skip": null,
"minute": 30,
"paused": false,
"portions": 3,
"mealNumber": 1,
"scheduleId": null
}
],
"created_at": "2021-12-17T07:07:31.047747+00:00"
}
},
"feeding_snack": [
{ "timestamp": "2022-09-04T03:03:00.000000+00:00", "amount": 0.125 },
{ "timestamp": "2022-08-30T16:34:00.000000+00:00", "amount": 0.25 }
],
"feeding_meal": [
{
"timestamp": "2022-09-08T18:00:00.000000+00:00",
"amount": 0.125,
"meal_name": "Lunch",
"meal_number": 2,
"meal_total_portions": 2
},
{
"timestamp": "2022-09-08T12:00:00.000000+00:00",
"amount": 0.125,
"meal_name": "Breakfast",
"meal_number": 1,
"meal_total_portions": 1
}
]
}

View File

@@ -1,15 +0,0 @@
{
"powerStatus": "AC",
"lastSeen": "2022-09-17T13:06:37.884Z",
"cleanCycleWaitTimeMinutes": "7",
"unitStatus": "RDY",
"litterRobotNickname": "Test",
"cycleCount": "15",
"panelLockActive": "0",
"cyclesAfterDrawerFull": "0",
"litterRobotSerial": "LR3C012345",
"cycleCapacity": "30",
"litterRobotId": "a0123b4567cd8e",
"nightLightActive": "1",
"sleepModeActive": "112:50:19"
}

View File

@@ -1,77 +0,0 @@
{
"name": "Test",
"serial": "LR4C010001",
"userId": "1234567",
"espFirmware": "1.1.50",
"picFirmwareVersion": "10512.2560.2.53",
"laserBoardFirmwareVersion": "4.0.65.4",
"wifiRssi": -53.0,
"unitPowerType": "AC",
"catWeight": 12.0,
"displayCode": "DC_MODE_IDLE",
"unitTimezone": "America/New_York",
"unitTime": null,
"cleanCycleWaitTime": 15,
"isKeypadLockout": false,
"nightLightMode": "OFF",
"nightLightBrightness": 50,
"isPanelSleepMode": false,
"panelBrightnessHigh": 50,
"panelSleepTime": 0,
"panelWakeTime": 0,
"weekdaySleepModeEnabled": {
"Sunday": { "sleepTime": 0, "wakeTime": 0, "isEnabled": false },
"Monday": { "sleepTime": 0, "wakeTime": 180, "isEnabled": true },
"Tuesday": { "sleepTime": 0, "wakeTime": 180, "isEnabled": true },
"Wednesday": { "sleepTime": 0, "wakeTime": 180, "isEnabled": true },
"Thursday": { "sleepTime": 0, "wakeTime": 180, "isEnabled": true },
"Friday": { "sleepTime": 0, "wakeTime": 180, "isEnabled": true },
"Saturday": { "sleepTime": 0, "wakeTime": 0, "isEnabled": false }
},
"unitPowerStatus": "ON",
"sleepStatus": "WAKE",
"robotStatus": "ROBOT_IDLE",
"globeMotorFaultStatus": "FAULT_CLEAR",
"pinchStatus": "CLEAR",
"catDetect": "CAT_DETECT_CLEAR",
"isBonnetRemoved": false,
"isNightLightLEDOn": false,
"odometerPowerCycles": 8,
"odometerCleanCycles": 158,
"odometerEmptyCycles": 1,
"odometerFilterCycles": 0,
"isDFIResetPending": false,
"DFINumberOfCycles": 104,
"DFILevelPercent": 76,
"isDFIFull": false,
"DFIFullCounter": 3,
"DFITriggerCount": 42,
"litterLevel": 460,
"DFILevelMM": 115,
"isCatDetectPending": false,
"globeMotorRetractFaultStatus": "FAULT_CLEAR",
"robotCycleStatus": "CYCLE_IDLE",
"robotCycleState": "CYCLE_STATE_WAIT_ON",
"weightSensor": -3.0,
"isOnline": true,
"isOnboarded": true,
"isProvisioned": true,
"isDebugModeActive": false,
"lastSeen": "2022-09-17T12:06:37.884Z",
"sessionId": "abcdef12-e358-4b6c-9022-012345678912",
"setupDateTime": "2022-08-28T17:01:12.644Z",
"isFirmwareUpdateTriggered": false,
"firmwareUpdateStatus": "NONE",
"wifiModeStatus": "ROUTER_CONNECTED",
"isUSBPowerOn": true,
"USBFaultStatus": "CLEAR",
"isDFIPartialFull": true,
"isLaserDirty": false,
"surfaceType": "TILE",
"hopperStatus": null,
"scoopsSavedCount": 3769,
"isHopperRemoved": null,
"optimalLitterLevel": 450,
"litterLevelPercentage": 0.7,
"litterLevelState": "OPTIMAL"
}

View File

@@ -1,19 +0,0 @@
{
"petId": "PET-123",
"userId": "1234567",
"createdAt": "2023-04-27T23:26:49.813Z",
"name": "Kitty",
"type": "CAT",
"gender": "FEMALE",
"lastWeightReading": 9.1,
"breeds": ["sphynx"],
"weightHistory": [
{ "weight": 6.48, "timestamp": "2025-06-13T16:12:36" },
{ "weight": 6.6, "timestamp": "2025-06-14T03:52:00" },
{ "weight": 6.59, "timestamp": "2025-06-14T17:20:32" },
{ "weight": 6.5, "timestamp": "2025-06-14T19:22:48" },
{ "weight": 6.35, "timestamp": "2025-06-15T03:12:15" },
{ "weight": 6.45, "timestamp": "2025-06-15T15:27:21" },
{ "weight": 6.25, "timestamp": "2025-06-15T15:29:26" }
]
}

View File

@@ -7,11 +7,11 @@ from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginExcepti
import pytest
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .common import ACCOUNT_USER_ID, CONFIG, DOMAIN
from .common import ACCOUNT_USER_ID, CONF_USERNAME, CONFIG, DOMAIN
from tests.common import MockConfigEntry
@@ -201,59 +201,3 @@ async def test_reauth_wrong_account(hass: HomeAssistant) -> None:
assert result["reason"] == "unique_id_mismatch"
assert entry.unique_id == ACCOUNT_USER_ID
assert entry.data == CONFIG[DOMAIN]
async def test_reconfigure(hass: HomeAssistant, mock_account: Account) -> None:
"""Test reconfiguration flow (with fail and recover)."""
entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG[DOMAIN],
unique_id=ACCOUNT_USER_ID,
)
entry.add_to_hass(hass)
original_password = entry.data[CONF_PASSWORD]
new_password = f"{original_password}_new"
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
with patch(
"homeassistant.components.litterrobot.config_flow.Account.connect",
side_effect=LitterRobotLoginException,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: new_password},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
assert entry.data[CONF_PASSWORD] == original_password
with (
patch(
"homeassistant.components.litterrobot.config_flow.Account.connect",
return_value=mock_account,
),
patch(
"homeassistant.components.litterrobot.config_flow.Account.user_id",
new_callable=PropertyMock,
return_value=ACCOUNT_USER_ID,
),
patch(
"homeassistant.components.litterrobot.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: new_password},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.unique_id == ACCOUNT_USER_ID
assert entry.data[CONF_PASSWORD] == new_password
assert len(mock_setup_entry.mock_calls) == 1

View File

@@ -0,0 +1,81 @@
"""Tests for the Litter-Robot coordinator."""
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
from homeassistant.components.litterrobot.const import DOMAIN
from homeassistant.components.litterrobot.coordinator import UPDATE_INTERVAL
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from .common import VACUUM_ENTITY_ID
from .conftest import setup_integration
from tests.common import async_fire_time_changed
async def test_coordinator_update_error(
hass: HomeAssistant,
mock_account: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities become unavailable when coordinator update fails."""
await setup_integration(hass, mock_account, VACUUM_DOMAIN)
assert (state := hass.states.get(VACUUM_ENTITY_ID))
assert state.state != STATE_UNAVAILABLE
# Simulate an API error during update
mock_account.refresh_robots.side_effect = LitterRobotException("Unable to connect")
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(VACUUM_ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
# Recover
mock_account.refresh_robots.side_effect = None
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(VACUUM_ENTITY_ID))
assert state.state != STATE_UNAVAILABLE
async def test_coordinator_update_auth_error(
hass: HomeAssistant,
mock_account: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test reauthentication flow is triggered on login error during update."""
entry = await setup_integration(hass, mock_account, VACUUM_DOMAIN)
assert (state := hass.states.get(VACUUM_ENTITY_ID))
assert state.state != STATE_UNAVAILABLE
# Simulate an authentication error during update
mock_account.refresh_robots.side_effect = LitterRobotLoginException(
"Invalid credentials"
)
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(VACUUM_ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
# Ensure a reauthentication flow was triggered
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == entry.entry_id

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