Compare commits

...

40 Commits

Author SHA1 Message Date
Joost Lekkerkerker
4efbafb003 Add TRMNL time platform (#165537)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-15 14:29:20 +01:00
Simone Chemelli
1b10db28f1 Add 100% coverage of coordinator for Fritz (#164074) 2026-03-15 12:16:42 +01:00
Joost Lekkerkerker
1e988fbb04 Remove stateclass from timestamp entity in Intellifire (#165403) 2026-03-15 11:39:57 +01:00
J. Nick Koston
9ab577aad4 Bump fnv-hash-fast to 2.0.0 (#165586) 2026-03-15 09:55:54 +01:00
Olivier R.
ed53469eb6 Fix KeyError 'api_domain' in Freebox zeroconf discovery (#165288)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-15 09:07:28 +01:00
Andres Ruiz
56aa96a00c Add re-auth flow for Waterfurnace (#165406) 2026-03-15 07:09:35 +01:00
Anis Kadri
99c6cdbe44 Bump py-unifi-access to 1.1.0 (#165576) 2026-03-15 06:58:27 +01:00
J. Diego Rodríguez Royo
1fd30b73e7 Add fan speed percentage to service schema (#165557) 2026-03-15 06:57:38 +01:00
Joost Lekkerkerker
14aace0c00 Add stale device handling to TRMNL (#165550) 2026-03-15 06:56:05 +01:00
Joost Lekkerkerker
6eed18623b Add reauthentication to TRMNL (#165546) 2026-03-15 06:54:26 +01:00
Joost Lekkerkerker
66ca7d5782 Add switch platform to TRMNL (#165539) 2026-03-15 06:49:09 +01:00
Joost Lekkerkerker
a7436cbdc3 Add diagnostics to TRMNL (#165544) 2026-03-15 06:48:13 +01:00
Joost Lekkerkerker
5e57b0272d Add diagnostics to Chess.com (#165563) 2026-03-15 06:47:37 +01:00
Raphael Hehl
e16b6ab026 Add emergency switch platform for UniFi Access integration (#165536)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-14 20:43:12 +01:00
Joost Lekkerkerker
e21fb14b9a Discover Aeotec hub for SmartThings (#165469) 2026-03-14 19:56:53 +01:00
Simone Chemelli
8e099a874b Bump aioamazondevices to 13.0.1 (#165476) 2026-03-14 19:46:02 +01:00
Åke Strandberg
a5302a6219 Fix missing code for Miele dishwasher (#165553) 2026-03-14 19:45:47 +01:00
Nathan Spencer
f761ac5b49 Add coordinator exception translations and mark entity/exception-translations rules as done (#165551) 2026-03-14 19:27:11 +01:00
Josh Gustafson
6988e73ddc Add sensor platform to Arcam FMJ (#165271)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:18:17 +01:00
Norbert Rittel
a88374557b Make "Power-on behavior" in zha consistent with matter and tuya (#165549) 2026-03-14 18:04:55 +01:00
Nathan Spencer
f2456b2c3a Add reconfiguration flow to Whisker (#165513) 2026-03-14 17:30:29 +01:00
Raphael Hehl
c1a525b7aa Add unifi_access to Ubiquiti brand and regenerate integrations.json (#165538)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-14 17:09:16 +01:00
Joost Lekkerkerker
9d2febd24e Add TRMNL integration (#165499) 2026-03-14 16:17:19 +01:00
Raphael Hehl
54f96bcc33 Add event platform for UniFi Access integration (#165531)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-14 14:12:50 +01:00
Manu
5582d83f7b Remove duplicate sensor entity description for monitor port in Uptime Kuma integration (#165479) 2026-03-14 14:05:48 +01:00
Joost Lekkerkerker
2832456bcd Add binary sensor for cooktop in SmartThings (#165481) 2026-03-14 14:05:24 +01:00
Norbert Rittel
070c5821e4 Make start_up_current_level in zha consistent with matter (#165504) 2026-03-14 13:58:01 +01:00
Lukas
07caa8ed2d Bump python-pooldose to 0.8.5 (#165507) 2026-03-14 13:57:20 +01:00
Kevin Stillhammer
b02f447e4d Bump pywaze to 1.2.0 (#165526) 2026-03-14 13:56:15 +01:00
Nathan Spencer
4fbb22e861 Update Whisker quality scale docs rules (#165510) 2026-03-14 11:38:29 +01:00
hanwg
45199a341f Pass web session to download files for Telegram bot (#165424) 2026-03-14 09:57:39 +01:00
Jan-Philipp Benecke
de5f42d7a0 Add progress reporting to WebDAV upload (#165398) 2026-03-14 08:35:47 +01:00
Artur Pragacz
4459dce73a Reorder code to group intent errors (#165431) 2026-03-13 18:58:19 -05:00
Artur Pragacz
a465905467 Remove speech parameter from service intent handler (#165225) 2026-03-13 18:57:16 -05:00
Raphael Hehl
a47faa3ced Add UniFi Access integration (#165404)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-14 00:00:18 +01:00
Josh
7276403ab9 Allow deleting UniFi client devices (#165505) 2026-03-13 23:06:58 +01:00
Raj Laud
018717af4f Fix victron_ble warning sensor using duplicate alarm translation key (#165502)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 22:23:54 +01:00
Norbert Rittel
274c2b8092 Shorten "Power-on behavior" name in matter to be consistent (#165490) 2026-03-13 21:22:49 +01:00
David Bishop
bfe15a55c9 Add entity-unavailable and log-when-unavailable (#165486)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:20:55 +00:00
dvdinth
54ad67b810 Bump pyintelliclima dependency for IntelliClima integration (#165478)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-13 20:16:27 +00:00
127 changed files with 7273 additions and 473 deletions

View File

@@ -570,6 +570,7 @@ 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.*

4
CODEOWNERS generated
View File

@@ -1770,6 +1770,8 @@ 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
@@ -1786,6 +1788,8 @@ build.json @home-assistant/supervisor
/tests/components/ukraine_alarm/ @PaulAnnekov
/homeassistant/components/unifi/ @Kane610
/tests/components/unifi/ @Kane610
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @RaHehl

View File

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

View File

@@ -1,6 +1,5 @@
"""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
@@ -25,20 +24,15 @@ 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=model,
model=self.device.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
if model != SPEAKER_GROUP_DEVICE_TYPE
else None
),
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
sw_version=self.device.software_version,
serial_number=serial_num,
)
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.0"]
"requirements": ["aioamazondevices==13.0.1"]
}

View File

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

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ArcamFmjCoordinator
@@ -12,9 +13,16 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
_attr_has_entity_name = True
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
def __init__(
self,
coordinator: ArcamFmjCoordinator,
description: EntityDescription | None = None,
) -> 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

@@ -0,0 +1,162 @@
"""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,5 +23,116 @@
"trigger_type": {
"turn_on": "{entity_name} was requested to turn on"
}
},
"entity": {
"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

@@ -0,0 +1,22 @@
"""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: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Can't detect a game

View File

@@ -15,7 +15,6 @@ 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},
@@ -27,7 +26,6 @@ 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,6 +103,8 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Initialize flow from zeroconf."""
zeroconf_properties = discovery_info.properties
host = zeroconf_properties["api_domain"]
port = zeroconf_properties["https_port"]
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
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"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."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -46,10 +46,12 @@ PROGRAM_OPTIONS = {
value,
)
for key, value in {
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.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.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
@@ -60,7 +62,10 @@ PROGRAM_OPTIONS = {
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
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_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==1.6.0",
"fnv-hash-fast==2.0.0",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -74,7 +74,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
"""Return the current speed percentage."""
device_data = self._device_data
if device_data.speed_set == FanSpeed.auto:
if device_data.speed_set == FanSpeed.auto_get:
return None
return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set))
@@ -92,7 +92,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
if device_data.mode_set == FanMode.off:
return None
if (
device_data.speed_set == FanSpeed.auto
device_data.speed_set == FanSpeed.auto_get
and device_data.mode_set == FanMode.sensor
):
return "auto"
@@ -111,7 +111,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
infinitely.
"""
percentage = 25 if percentage == 0 else percentage
await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage)
await self.async_set_mode_speed(preset_mode=preset_mode, percentage=percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
@@ -124,10 +124,10 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
await self.async_set_mode_speed(fan_mode=preset_mode)
await self.async_set_mode_speed(preset_mode=preset_mode)
async def async_set_mode_speed(
self, fan_mode: str | None = None, percentage: int | None = None
self, preset_mode: str | None = None, percentage: int | None = None
) -> None:
"""Set mode and speed.
@@ -137,7 +137,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
percentage = self.percentage if percentage is None else percentage
percentage = 25 if percentage is None else percentage
if fan_mode == "auto":
if preset_mode == "auto":
# auto is a special case with special mode and speed setting
await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn)
await self.coordinator.async_request_refresh()
@@ -148,21 +148,20 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
return
# Determine the fan mode
if fan_mode is not None:
# Set to requested fan_mode
mode = fan_mode
elif not self.is_on:
if not self.is_on:
# Default to alternate fan mode if not turned on
mode = FanMode.alternate
else:
# Maintain current mode
mode = self._device_data.mode_set
speed = str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
speed = FanSpeed(
str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
)
)
)
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyintelliclima==0.2.2"]
"requirements": ["pyintelliclima==0.3.1"]
}

View File

@@ -68,12 +68,12 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
if (
device_data.speed_set == FanSpeed.auto
device_data.speed_set == FanSpeed.auto_get
and device_data.mode_set == FanMode.sensor
):
return None
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
return INTELLICLIMA_MODE_TO_FAN_MODE.get(device_data.mode_set)
async def async_select_option(self, option: str) -> None:
"""Set the fan mode."""
@@ -83,7 +83,7 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
# Determine speed: keep current speed if available, otherwise default to sleep
if (
device_data.speed_set == FanSpeed.auto
device_data.speed_set == FanSpeed.auto_get
or device_data.mode_set == FanMode.off
):
speed = FanSpeed.sleep

View File

@@ -6,7 +6,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No IntelliClima devices found in your account",
"no_devices": "No supported IntelliClima devices were found in your account",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {

View File

@@ -114,7 +114,6 @@ 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 ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -21,6 +21,7 @@ _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):
@@ -43,24 +44,45 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Handle user's reauth credentials."""
errors = {}
errors: dict[str, str] = {}
if user_input:
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
)
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
errors["base"] = error
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA,
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:
@@ -81,6 +103,25 @@ 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

@@ -46,11 +46,22 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Update all device states from the Litter-Robot API."""
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
await pet.fetch_weight_history()
try:
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# 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
except LitterRobotException as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": str(ex)},
) from ex
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -63,9 +74,15 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
load_pets=True,
)
except LitterRobotLoginException as ex:
raise ConfigEntryAuthFailed("Invalid credentials") from ex
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_credentials"
) from ex
except LitterRobotException as ex:
raise UpdateFailed("Unable to connect to Whisker API") from ex
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": str(ex)},
) from ex
def litter_robots(self) -> Generator[LitterRobot]:
"""Get Litter-Robots from the account."""

View File

@@ -29,9 +29,9 @@ rules:
status: done
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
@@ -49,23 +49,21 @@ rules:
discovery:
status: todo
comment: Need to validate discovery
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations:
status: todo
comment: Make sure all translated states are in sentence case
exception-translations: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: done
comment: |

View File

@@ -3,6 +3,7 @@
"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": {
@@ -21,6 +22,14 @@
"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%]",
@@ -196,11 +205,17 @@
}
},
"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

@@ -325,11 +325,11 @@
}
},
"startup_on_off": {
"name": "Power-on behavior on startup",
"name": "Power-on behavior",
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"previous": "Previous",
"previous": "Previous state",
"toggle": "[%key:common::action::toggle%]"
}
},

View File

@@ -500,6 +500,7 @@ 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,6 +759,7 @@
"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

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

View File

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

View File

@@ -200,6 +200,14 @@ 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,6 +25,10 @@
"hostname": "hub*",
"macaddress": "286D97*"
},
{
"hostname": "smarthub",
"macaddress": "683A48*"
},
{
"hostname": "samsung-*"
}

View File

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

View File

@@ -0,0 +1,29 @@
"""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

@@ -0,0 +1,64 @@
"""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, 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})
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 or reauth."""
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]},
)
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,
)
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()

View File

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

View File

@@ -0,0 +1,69 @@
"""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

@@ -0,0 +1,25 @@
"""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

@@ -0,0 +1,39 @@
"""Base class for TRMNL entities."""
from __future__ import annotations
from trmnl.models import Device
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

View File

@@ -0,0 +1,20 @@
{
"entity": {
"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

@@ -0,0 +1,11 @@
{
"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.0"]
}

View File

@@ -0,0 +1,74 @@
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: todo
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: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
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

@@ -0,0 +1,92 @@
"""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,
)
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="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,
),
)
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
async_add_entities(
TRMNLSensor(coordinator, device_id, description)
for device_id in coordinator.data
for description in SENSOR_DESCRIPTIONS
)
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

@@ -0,0 +1,47 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_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%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key for your TRMNL account."
}
}
}
},
"entity": {
"switch": {
"sleep_mode": {
"name": "Sleep mode"
}
},
"time": {
"sleep_end_time": {
"name": "Sleep end time"
},
"sleep_start_time": {
"name": "Sleep start time"
}
}
},
"exceptions": {
"authentication_error": {
"message": "Authentication failed. Please check your API key."
},
"update_error": {
"message": "An error occurred while communicating with TRMNL: {error}"
}
}
}

View File

@@ -0,0 +1,91 @@
"""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
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
async_add_entities(
TRMNLSwitchEntity(coordinator, device_id, description)
for device_id in coordinator.data
for description in SWITCH_DESCRIPTIONS
)
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)
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()
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

@@ -0,0 +1,108 @@
"""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
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
async_add_entities(
TRMNLTimeEntity(coordinator, device_id, description)
for device_id in coordinator.data
for description in TIME_DESCRIPTIONS
)
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)
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

@@ -76,9 +76,7 @@ async def async_remove_config_entry_device(
"""Remove config entry from a device."""
hub = config_entry.runtime_data
return not any(
identifier
for _, identifier in device_entry.connections
if identifier in hub.api.clients or identifier in hub.api.devices
identifier in hub.api.devices for _, identifier in device_entry.connections
)

View File

@@ -0,0 +1,54 @@
"""The UniFi Access integration."""
from __future__ import annotations
from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool:
"""Set up UniFi Access from a config entry."""
session = async_get_clientsession(hass, verify_ssl=entry.data[CONF_VERIFY_SSL])
client = UnifiAccessApiClient(
host=entry.data[CONF_HOST],
api_token=entry.data[CONF_API_TOKEN],
session=session,
verify_ssl=entry.data[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError as err:
raise ConfigEntryNotReady(
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
) from err
except ApiConnectionError as err:
raise ConfigEntryNotReady(
f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}"
) from err
coordinator = UnifiAccessCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_on_unload(client.close)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: UnifiAccessConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,53 @@
"""Button platform for the UniFi Access integration."""
from __future__ import annotations
from unifi_access_api import Door, UnifiAccessError
from homeassistant.components.button import ButtonEntity
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
from .entity import UnifiAccessEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up UniFi Access button entities."""
coordinator = entry.runtime_data
async_add_entities(
UnifiAccessUnlockButton(coordinator, door)
for door in coordinator.data.doors.values()
)
class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity):
"""Representation of a UniFi Access door unlock button."""
_attr_translation_key = "unlock"
def __init__(
self,
coordinator: UnifiAccessCoordinator,
door: Door,
) -> None:
"""Initialize the button entity."""
super().__init__(coordinator, door, "unlock")
async def async_press(self) -> None:
"""Unlock the door."""
try:
await self.coordinator.client.unlock_door(self._door_id)
except UnifiAccessError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unlock_failed",
) from err

View File

@@ -0,0 +1,68 @@
"""Config flow for UniFi Access integration."""
from __future__ import annotations
import logging
from typing import Any
from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for UniFi Access."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = UnifiAccessApiClient(
host=user_input[CONF_HOST],
api_token=user_input[CONF_API_TOKEN],
session=session,
verify_ssl=user_input[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError:
errors["base"] = "invalid_auth"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
return self.async_create_entry(
title="UniFi Access",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_TOKEN): str,
vol.Required(CONF_VERIFY_SSL, default=False): bool,
}
),
errors=errors,
)

View File

@@ -0,0 +1,3 @@
"""Constants for the UniFi Access integration."""
DOMAIN = "unifi_access"

View File

@@ -0,0 +1,240 @@
"""Data update coordinator for the UniFi Access integration."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, 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.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_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]):
"""Coordinator for fetching UniFi Access door data."""
config_entry: UnifiAccessConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
client: UnifiAccessApiClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
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,
on_connect=self._on_ws_connect,
on_disconnect=self._on_ws_disconnect,
)
async def _async_update_data(self) -> UnifiAccessData:
"""Fetch all doors and emergency status from the API."""
try:
async with asyncio.timeout(10):
doors, emergency = await asyncio.gather(
self.client.get_doors(),
self.client.get_emergency_status(),
)
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,
)
def _on_ws_connect(self) -> None:
"""Handle WebSocket connection established."""
_LOGGER.debug("WebSocket connected to UniFi Access")
if not self.last_update_success:
self.config_entry.async_create_background_task(
self.hass,
self.async_request_refresh(),
"unifi_access_reconnect_refresh",
)
def _on_ws_disconnect(self) -> None:
"""Handle WebSocket disconnection."""
_LOGGER.warning("WebSocket disconnected from UniFi Access")
self.async_set_update_error(
UpdateFailed("WebSocket disconnected from UniFi Access")
)
async def _handle_location_update(self, msg: WebsocketMessage) -> None:
"""Handle location_update_v2 messages."""
update = cast(LocationUpdateV2, msg)
self._process_door_update(update.data.id, update.data.state)
async def _handle_v2_location_update(self, msg: WebsocketMessage) -> None:
"""Handle V2 location update messages."""
update = cast(V2LocationUpdate, msg)
self._process_door_update(update.data.id, update.data.state)
def _process_door_update(
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:
return
if ws_state is None:
return
current_door = self.data.doors[door_id]
updates: dict[str, object] = {}
if ws_state.dps is not None:
updates["door_position_status"] = ws_state.dps
if ws_state.lock == "locked":
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)

View File

@@ -0,0 +1,58 @@
"""Base entity for the UniFi Access integration."""
from __future__ import annotations
from unifi_access_api import Door
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import UnifiAccessCoordinator
class UnifiAccessEntity(CoordinatorEntity[UnifiAccessCoordinator]):
"""Base entity for UniFi Access doors."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: UnifiAccessCoordinator,
door: Door,
key: str,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._door_id = door.id
self._attr_unique_id = f"{door.id}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, door.id)},
name=door.name,
manufacturer="Ubiquiti",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._door_id in self.coordinator.data.doors
@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",
)

View File

@@ -0,0 +1,96 @@
"""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

@@ -0,0 +1,22 @@
{
"entity": {
"button": {
"unlock": {
"default": "mdi:lock-open"
}
},
"event": {
"access": {
"default": "mdi:door"
}
},
"switch": {
"evacuation": {
"default": "mdi:exit-run"
},
"lockdown": {
"default": "mdi:lock-alert"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"domain": "unifi_access",
"name": "UniFi Access",
"codeowners": ["@imhotep", "@RaHehl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/unifi_access",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["unifi_access_api"],
"quality_scale": "bronze",
"requirements": ["py-unifi-access==1.1.0"]
}

View File

@@ -0,0 +1,66 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: exempt
comment: Integration uses WebSocket push updates, no polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
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: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
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: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,72 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
"host": "[%key:common::config_flow::data::host%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_token": "API token generated in the UniFi Access settings.",
"host": "Hostname or IP address of the UniFi Access controller.",
"verify_ssl": "Verify the SSL certificate of the controller."
}
}
}
},
"entity": {
"button": {
"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

@@ -0,0 +1,110 @@
"""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,13 +116,6 @@ 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

@@ -398,7 +398,7 @@ SENSOR_DESCRIPTIONS = {
Keys.WARNING: VictronBLESensorEntityDescription(
key=Keys.WARNING,
device_class=SensorDeviceClass.ENUM,
translation_key="alarm",
translation_key="warning",
options=ALARM_OPTIONS,
),
Keys.YIELD_TODAY: VictronBLESensorEntityDescription(

View File

@@ -248,7 +248,24 @@
"name": "[%key:component::victron_ble::common::starter_voltage%]"
},
"warning": {
"name": "Warning"
"name": "Warning",
"state": {
"bms_lockout": "[%key:component::victron_ble::entity::sensor::alarm::state::bms_lockout%]",
"dc_ripple": "[%key:component::victron_ble::entity::sensor::alarm::state::dc_ripple%]",
"high_starter_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::high_starter_voltage%]",
"high_temperature": "[%key:component::victron_ble::entity::sensor::alarm::state::high_temperature%]",
"high_v_ac_out": "[%key:component::victron_ble::entity::sensor::alarm::state::high_v_ac_out%]",
"high_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::high_voltage%]",
"low_soc": "[%key:component::victron_ble::entity::sensor::alarm::state::low_soc%]",
"low_starter_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::low_starter_voltage%]",
"low_temperature": "[%key:component::victron_ble::entity::sensor::alarm::state::low_temperature%]",
"low_v_ac_out": "[%key:component::victron_ble::entity::sensor::alarm::state::low_v_ac_out%]",
"low_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::low_voltage%]",
"mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]",
"no_alarm": "[%key:component::victron_ble::entity::sensor::alarm::state::no_alarm%]",
"overload": "[%key:component::victron_ble::entity::sensor::alarm::state::overload%]",
"short_circuit": "[%key:component::victron_ble::entity::sensor::alarm::state::short_circuit%]"
}
},
"yield_today": {
"name": "Yield today"

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -72,6 +73,58 @@ 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,7 +4,9 @@
"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%]",
"unknown": "Unexpected error, please try again."
"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."
},
"error": {
"cannot_connect": "Failed to connect to WaterFurnace service",
@@ -12,6 +14,18 @@
"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.1.1"]
"requirements": ["pywaze==1.2.0"]
}

View File

@@ -156,6 +156,7 @@ 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": "Start-up current level"
"name": "Power-on level"
},
"startup_time": {
"name": "Startup time"
@@ -1298,7 +1298,7 @@
"name": "Speed"
},
"start_up_on_off": {
"name": "Start-up behavior"
"name": "Power-on behavior"
},
"status_indication": {
"name": "Status indication"

View File

@@ -743,6 +743,7 @@ FLOWS = {
"trane",
"transmission",
"triggercmd",
"trmnl",
"tuya",
"twentemilieu",
"twilio",
@@ -751,6 +752,7 @@ FLOWS = {
"uhoo",
"ukraine_alarm",
"unifi",
"unifi_access",
"unifiprotect",
"upb",
"upcloud",

View File

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

View File

@@ -7244,6 +7244,12 @@
"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",
@@ -7325,6 +7331,12 @@
"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,

View File

@@ -184,6 +184,52 @@ 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."""
@@ -232,6 +278,29 @@ 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."""
@@ -292,75 +361,6 @@ 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,7 +927,6 @@ 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
@@ -1114,7 +1113,6 @@ class DynamicServiceIntentHandler(IntentHandler):
)
for floor in match_result.floors
)
speech_name = match_result.floors[0].name
elif match_result.areas:
success_results.extend(
IntentResponseTarget(
@@ -1122,9 +1120,6 @@ 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:
@@ -1166,9 +1161,6 @@ 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(
@@ -1231,7 +1223,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,
@@ -1244,7 +1236,6 @@ 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

@@ -33,7 +33,7 @@ cronsim==2.7
cryptography==46.0.5
dbus-fast==3.1.2
file-read-backwards==2.0.0
fnv-hash-fast==1.6.0
fnv-hash-fast==2.0.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.9.1

10
mypy.ini generated
View File

@@ -5458,6 +5458,16 @@ 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

@@ -48,7 +48,7 @@ dependencies = [
"certifi>=2021.5.30",
"ciso8601==2.3.3",
"cronsim==2.7",
"fnv-hash-fast==1.6.0",
"fnv-hash-fast==2.0.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==2.0.0",

2
requirements.txt generated
View File

@@ -23,7 +23,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==46.0.5
fnv-hash-fast==1.6.0
fnv-hash-fast==2.0.0
ha-ffmpeg==3.2.2
hass-nabucasa==2.0.0
hassil==3.5.0

16
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.0
aioamazondevices==13.0.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -997,7 +997,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
fnv-hash-fast==1.6.0
fnv-hash-fast==2.0.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -1882,6 +1882,9 @@ py-sucks==0.9.11
# homeassistant.components.synology_dsm
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.1.0
# homeassistant.components.atome
pyAtome==0.1.1
@@ -2158,7 +2161,7 @@ pyicloud==2.4.1
pyinsteon==1.6.4
# homeassistant.components.intelliclima
pyintelliclima==0.2.2
pyintelliclima==0.3.1
# homeassistant.components.intesishome
pyintesishome==1.8.0
@@ -2633,7 +2636,7 @@ python-overseerr==0.9.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.2
python-pooldose==0.8.5
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2727,7 +2730,7 @@ pyvlx==0.2.30
pyvolumio==0.1.5
# homeassistant.components.waze_travel_time
pywaze==1.1.1
pywaze==1.2.0
# homeassistant.components.weatherflow
pyweatherflowudp==1.5.0
@@ -3126,6 +3129,9 @@ transmission-rpc==7.0.3
# homeassistant.components.triggercmd
triggercmd==0.0.36
# homeassistant.components.trmnl
trmnl==0.1.0
# 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.0
aioamazondevices==13.0.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -882,7 +882,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
fnv-hash-fast==1.6.0
fnv-hash-fast==2.0.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -1631,6 +1631,9 @@ py-sucks==0.9.11
# homeassistant.components.synology_dsm
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.1.0
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -1847,7 +1850,7 @@ pyicloud==2.4.1
pyinsteon==1.6.4
# homeassistant.components.intelliclima
pyintelliclima==0.2.2
pyintelliclima==0.3.1
# homeassistant.components.ipma
pyipma==3.0.9
@@ -2232,7 +2235,7 @@ python-overseerr==0.9.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.2
python-pooldose==0.8.5
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2311,7 +2314,7 @@ pyvlx==0.2.30
pyvolumio==0.1.5
# homeassistant.components.waze_travel_time
pywaze==1.1.1
pywaze==1.2.0
# homeassistant.components.weatherflow
pyweatherflowudp==1.5.0
@@ -2629,6 +2632,9 @@ transmission-rpc==7.0.3
# homeassistant.components.triggercmd
triggercmd==0.0.36
# homeassistant.components.trmnl
trmnl==0.1.0
# homeassistant.components.twinkly
ttls==1.8.3

View File

@@ -2,10 +2,7 @@
from unittest.mock import AsyncMock
from aioamazondevices.const.devices import (
SPEAKER_GROUP_DEVICE_TYPE,
SPEAKER_GROUP_FAMILY,
)
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
import pytest
@@ -117,7 +114,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_DEVICE_TYPE,
model="Speaker Group",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -156,7 +153,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_DEVICE_TYPE,
model="Speaker Group",
entry_type=dr.DeviceEntryType.SERVICE,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
"""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

@@ -0,0 +1,59 @@
# 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

@@ -0,0 +1,29 @@
"""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.speech["plain"]["speech"] == "Opening garage door"
assert response.response_type == intent.IntentResponseType.ACTION_DONE
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.speech["plain"]["speech"] == "Closing garage door"
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN

View File

@@ -165,3 +165,26 @@ 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,13 +1,15 @@
"""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
@@ -17,83 +19,101 @@ 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(Service):
class FritzServiceMock:
"""Service mocking."""
def __init__(self, serviceId: str, actions: dict) -> None:
def __init__(self, actions: list[str]) -> None:
"""Init Service mock."""
super().__init__()
self._actions = actions
self.serviceId = serviceId
self.actions = actions
class FritzConnectionMock:
"""FritzConnection mocking."""
def __init__(self, services) -> None:
def __init__(self, fc_data: dict[str, dict[str, Any]]) -> 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.call_action = self._call_action
self._services = services
self.services = {
srv: FritzServiceMock(serviceId=srv, actions=actions)
for srv, actions in services.items()
}
self._side_effect: Exception | None = None
self._service_normalization(fc_data)
LOGGER.debug("-" * 80)
LOGGER.debug("FritzConnectionMock - services: %s", self.services)
def call_action_side_effect(self, side_effect=None) -> None:
"""Set or unset a side_effect for call_action."""
if side_effect is not None:
self.call_action = MagicMock(side_effect=side_effect)
else:
self.call_action = self._call_action
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 override_services(self, services) -> None:
"""Overrire services data."""
self._services = services
def call_action_side_effect(self, side_effect: Exception | None) -> None:
"""Set or unset a side_effect for call_action."""
self._side_effect = side_effect
def override_services(self, fc_data: dict[str, dict[str, Any]]) -> None:
"""Override services data."""
self._service_normalization(fc_data)
def clear_cache(self) -> None:
"""Mock clear_cache method."""
return FritzConnectionCached.clear_cache(self)
def _call_action(self, service: str, action: str, **kwargs):
def call_action(self, service: str, action: str, **kwargs: Any) -> Any:
"""Simulate TR-064 call with service name normalization."""
LOGGER.debug(
"_call_action service: %s, action: %s, **kwargs: %s",
service,
action,
{**kwargs},
)
if ":" in service:
service, number = service.split(":", 1)
service = service + number
elif not service[-1].isnumeric():
service = service + "1"
if self._side_effect:
raise self._side_effect
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 self._services[service][action][index]
return self._services[service][action]
return action_data
@pytest.fixture(name="fc_data")
def fc_data_mock() -> dict[str, dict]:
def fc_data_mock() -> dict[str, dict[str, Any]]:
"""Fixture for default fc_data."""
return MOCK_FB_SERVICES
return deepcopy(MOCK_FB_SERVICES)
@pytest.fixture
def fc_class_mock(fc_data: dict[str, dict]) -> Generator[FritzConnectionMock]:
def fc_class_mock(fc_data: dict[str, dict[str, Any]]) -> Generator[MagicMock]:
"""Fixture that sets up a mocked FritzConnection class."""
with patch(
"homeassistant.components.fritz.coordinator.FritzConnectionCached",
@@ -138,20 +158,10 @@ 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,5 +1,7 @@
"""Common stuff for Fritz!Tools tests."""
from typing import Any
from fritzconnection.lib.fritzstatus import DefaultConnectionService
from homeassistant.components.fritz.const import DOMAIN
@@ -54,7 +56,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] = {
MOCK_FB_SERVICES: dict[str, dict[str, Any]] = {
"DeviceInfo1": {
"GetInfo": {
"NewSerialNumber": MOCK_MESH_MASTER_MAC,

View File

@@ -2,9 +2,16 @@
from __future__ import annotations
from collections.abc import Generator
from copy import deepcopy
from unittest.mock import MagicMock, patch
from typing import cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
FritzSecurityError,
)
from fritzconnection.lib.fritztools import ArgumentNamespace
import pytest
@@ -14,7 +21,12 @@ from homeassistant.components.fritz.const import (
DEFAULT_SSL,
DOMAIN,
)
from homeassistant.components.fritz.coordinator import AvmWrapper, ClassSetupMissing
from homeassistant.components.fritz.coordinator import (
AvmWrapper,
ClassSetupMissing,
FritzBoxTools,
FritzConnectionCached,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_HOST,
@@ -24,13 +36,58 @@ 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",
[
@@ -127,12 +184,13 @@ async def test_no_software_version(
device_info = deepcopy(MOCK_STATUS_DEVICE_INFO_DATA)
device_info["NewSoftwareVersion"] = "string_version_not_number"
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)
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)
assert entry.state is ConfigEntryState.LOADED
@@ -141,3 +199,371 @@ 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

@@ -4,6 +4,7 @@ from collections.abc import Generator
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from pyintelliclima.const import FanMode, FanSpeed
from pyintelliclima.intelliclima_types import (
IntelliClimaDevices,
IntelliClimaECO,
@@ -50,9 +51,9 @@ def single_eco_device() -> IntelliClimaDevices:
model=IntelliClimaModelType(modello="ECO", tipo="wifi"),
name="Test VMC",
houses_id="12345",
mode_set="1",
mode_set=FanMode.inward,
mode_state="1",
speed_set="3",
speed_set=FanSpeed.medium,
speed_state="3",
last_online="2025-11-18 10:22:51",
creation_date="2025-11-18 10:22:51",

View File

@@ -3,6 +3,7 @@
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, patch
from pyintelliclima.const import FanMode, FanSpeed
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -103,7 +104,7 @@ async def test_fan_turn_on_service_calls_api(
# Device serial from single_eco_device.crono_sn
mock_cloud_interface.ecocomfort.set_mode_speed.assert_awaited_once_with(
"11223344", "1", "2"
"11223344", FanMode.inward, FanSpeed.low
)
@@ -119,10 +120,10 @@ async def test_fan_set_percentage_maps_to_speed(
{ATTR_ENTITY_ID: FAN_ENTITY_ID, ATTR_PERCENTAGE: 15},
blocking=True,
)
# Initial mode_set="1" (forward) from single_eco_device.
# Sleep speed is "1" (25%).
# Initial mode_set=FanMode.inward from single_eco_device.
# Sleep speed is FanSpeed.sleep (25%).
mock_cloud_interface.ecocomfort.set_mode_speed.assert_awaited_once_with(
"11223344", "1", "1"
"11223344", FanMode.inward, FanSpeed.sleep
)
@@ -165,18 +166,18 @@ async def test_fan_set_percentage_zero_turns_off(
("service_data", "expected_mode", "expected_speed"),
[
# percentage=None, preset_mode=None -> defaults to previous speed > 75% (medium),
# previous mode > "inward"
({}, "1", "3"),
# percentage=0, preset_mode=None -> default 25% (sleep), previous mode (inward)
({ATTR_PERCENTAGE: 0}, "1", "1"),
# previous mode > FanMode.inward
({}, FanMode.inward, FanSpeed.medium),
# percentage=0, preset_mode=None -> default 25% (FanSpeed.sleep), previous mode (inward)
({ATTR_PERCENTAGE: 0}, FanMode.inward, FanSpeed.sleep),
],
)
async def test_fan_turn_on_defaulting_behavior(
hass: HomeAssistant,
mock_cloud_interface: AsyncMock,
service_data: dict,
expected_mode: str,
expected_speed: str,
expected_mode: FanMode,
expected_speed: FanSpeed,
) -> None:
"""turn_on defaults percentage/preset as expected."""
data = {ATTR_ENTITY_ID: FAN_ENTITY_ID} | service_data

View File

@@ -86,10 +86,10 @@ async def test_select_option_keeps_current_speed(
{ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: option},
blocking=True,
)
# Device starts with speed_set="3" (from single_eco_device in conftest),
# Device starts with speed_set=FanSpeed.medium (from single_eco_device in conftest),
# mode is not off and not auto, so current speed is preserved.
mock_cloud_interface.ecocomfort.set_mode_speed.assert_awaited_once_with(
"11223344", expected_mode, "3"
"11223344", expected_mode, FanSpeed.medium
)
@@ -119,9 +119,9 @@ async def test_select_option_in_auto_mode_defaults_speed_to_sleep(
mock_cloud_interface: AsyncMock,
single_eco_device,
) -> None:
"""When speed_set is FanSpeed.auto (auto preset), selecting an option defaults to sleep speed."""
"""When speed_set is FanSpeed.auto_get (auto preset), selecting an option defaults to sleep speed."""
eco = list(single_eco_device.ecocomfort2_devices.values())[0]
eco.speed_set = FanSpeed.auto
eco.speed_set = FanSpeed.auto_get
eco.mode_set = FanMode.sensor
await hass.services.async_call(

View File

@@ -547,9 +547,7 @@
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -586,7 +584,6 @@
'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,12 +248,11 @@ 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)
response = await intent.async_handle(
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

@@ -201,3 +201,59 @@ 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

File diff suppressed because it is too large Load Diff

View File

@@ -86,15 +86,12 @@ async def test_attribute_select_entities(
matter_node: MatterNode,
) -> None:
"""Test select entities are created for attribute based discovery schema(s)."""
entity_id = "select.mock_dimmable_light_power_on_behavior_on_startup"
entity_id = "select.mock_dimmable_light_power_on_behavior"
state = hass.states.get(entity_id)
assert state
assert state.state == "previous"
assert state.attributes["options"] == ["on", "off", "toggle", "previous"]
assert (
state.attributes["friendly_name"]
== "Mock Dimmable Light Power-on behavior on startup"
)
assert state.attributes["friendly_name"] == "Mock Dimmable Light Power-on behavior"
set_node_attribute(matter_node, 1, 6, 16387, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)

View File

@@ -298,6 +298,55 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_cooktop_000001][binary_sensor.table_de_cuisson_operating_state-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': None,
'entity_id': 'binary_sensor.table_de_cuisson_operating_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Operating state',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Operating state',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cooktop_operating_state',
'unique_id': '5c202ad1-d112-d746-50b8-bd76a554b362_main_custom.cooktopOperatingState_cooktopOperatingState_cooktopOperatingState',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_cooktop_000001][binary_sensor.table_de_cuisson_operating_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Table de cuisson Operating state',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.table_de_cuisson_operating_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_cooktop_000001][binary_sensor.table_de_cuisson_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1040,6 +1089,55 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_operating_state-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': None,
'entity_id': 'binary_sensor.vulcan_operating_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Operating state',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Operating state',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cooktop_operating_state',
'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_custom.cooktopOperatingState_cooktopOperatingState_cooktopOperatingState',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_operating_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Vulcan Operating state',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.vulcan_operating_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -0,0 +1,13 @@
"""Tests for the TRMNL integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the TRMNL integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,55 @@
"""Common fixtures for the TRMNL tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from trmnl.models import DevicesResponse, UserResponse
from homeassistant.components.trmnl.const import DOMAIN
from homeassistant.const import CONF_API_KEY
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.trmnl.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Test",
unique_id="30561",
data={CONF_API_KEY: "user_aaaaaaaaaa"},
)
@pytest.fixture
def mock_trmnl_client() -> Generator[AsyncMock]:
"""Mock TRMNL client."""
with (
patch(
"homeassistant.components.trmnl.coordinator.TRMNLClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.trmnl.config_flow.TRMNLClient",
new=mock_client,
),
):
client = mock_client.return_value
client.get_me.return_value = UserResponse.from_json(
load_fixture("me.json", DOMAIN)
).data
client.get_devices.return_value = DevicesResponse.from_json(
load_fixture("devices.json", DOMAIN)
).data
yield client

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