mirror of
https://github.com/home-assistant/core.git
synced 2026-09-04 02:34:40 -05:00
2026.4.2 (#167939)
This commit is contained in:
@@ -54,7 +54,16 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_LOGIN_DATA],
|
||||
)
|
||||
self.previous_devices: set[str] = set()
|
||||
device_registry = dr.async_get(hass)
|
||||
self.previous_devices: set[str] = {
|
||||
identifier
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
)
|
||||
if device.entry_type != dr.DeviceEntryType.SERVICE
|
||||
for identifier_domain, identifier in device.identifiers
|
||||
if identifier_domain == DOMAIN
|
||||
}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
|
||||
@@ -92,6 +92,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
_LOGGER.debug("Updating statistics for the first time")
|
||||
usage_sum = 0.0
|
||||
last_stats_time = None
|
||||
allow_update_last_stored_hour = False
|
||||
else:
|
||||
if not meter.readings or len(meter.readings) == 0:
|
||||
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||
@@ -107,6 +108,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
_LOGGER.debug("Getting statistics at %s", start)
|
||||
stats: dict[str, list[Any]] = {}
|
||||
for end in (start + timedelta(seconds=1), None):
|
||||
stats = await get_instance(self.hass).async_add_executor_job(
|
||||
statistics_during_period,
|
||||
@@ -127,15 +129,28 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"Not found, trying to find oldest statistic after %s",
|
||||
start,
|
||||
)
|
||||
assert stats
|
||||
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
if not stats or not stats.get(usage_statistic_id):
|
||||
_LOGGER.debug(
|
||||
"Could not find existing statistics during period lookup for %s, "
|
||||
"falling back to last stored statistic",
|
||||
usage_statistic_id,
|
||||
)
|
||||
allow_update_last_stored_hour = True
|
||||
last_records = last_stat[usage_statistic_id]
|
||||
usage_sum = float(last_records[0].get("sum") or 0.0)
|
||||
last_stats_time = last_records[0]["start"]
|
||||
else:
|
||||
allow_update_last_stored_hour = False
|
||||
records = stats[usage_statistic_id]
|
||||
|
||||
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
|
||||
last_stats_time = stats[usage_statistic_id][0]["start"]
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
|
||||
usage_sum = _safe_get_sum(records)
|
||||
last_stats_time = records[0]["start"]
|
||||
|
||||
usage_statistics = []
|
||||
|
||||
@@ -148,7 +163,13 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||
if last_stats_time is not None and (
|
||||
start.timestamp() < last_stats_time
|
||||
or (
|
||||
start.timestamp() == last_stats_time
|
||||
and not allow_update_last_stored_hour
|
||||
)
|
||||
):
|
||||
continue
|
||||
usage_state = max(0, read["consumption"] / 1000)
|
||||
usage_sum = max(0, read["read"])
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==67"],
|
||||
"requirements": ["axis==68"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -74,6 +74,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except exception.BadRequest as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bad_request",
|
||||
translation_placeholders={"error_message": str(err)},
|
||||
) from err
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
|
||||
@@ -101,8 +101,7 @@ def handle_b2_errors[T](
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except B2Error as err:
|
||||
error_msg = f"Failed during {func.__name__}"
|
||||
raise BackupAgentError(error_msg) from err
|
||||
raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -170,8 +169,7 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
async def _cleanup_failed_upload(self, filename: str) -> None:
|
||||
"""Clean up a partially uploaded file after upload failure."""
|
||||
_LOGGER.warning(
|
||||
"Attempting to delete partially uploaded main backup file %s "
|
||||
"due to metadata upload failure",
|
||||
"Attempting to delete partially uploaded backup file %s",
|
||||
filename,
|
||||
)
|
||||
try:
|
||||
@@ -180,11 +178,10 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
)
|
||||
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
|
||||
except B2Error:
|
||||
_LOGGER.debug(
|
||||
"Failed to clean up partially uploaded main backup file %s. "
|
||||
"Manual intervention may be required to delete it from Backblaze B2",
|
||||
_LOGGER.warning(
|
||||
"Failed to clean up partially uploaded backup file %s;"
|
||||
" manual deletion from Backblaze B2 may be required",
|
||||
filename,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
@@ -256,9 +253,10 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
prefixed_metadata_filename,
|
||||
)
|
||||
|
||||
upload_successful = False
|
||||
tar_uploaded = False
|
||||
try:
|
||||
await self._upload_backup_file(prefixed_tar_filename, open_stream, {})
|
||||
tar_uploaded = True
|
||||
_LOGGER.debug(
|
||||
"Main backup file upload finished for %s", prefixed_tar_filename
|
||||
)
|
||||
@@ -270,15 +268,14 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
_LOGGER.debug(
|
||||
"Metadata file upload finished for %s", prefixed_metadata_filename
|
||||
)
|
||||
upload_successful = True
|
||||
finally:
|
||||
if upload_successful:
|
||||
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
|
||||
self._invalidate_caches(
|
||||
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
|
||||
self._invalidate_caches(
|
||||
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
|
||||
)
|
||||
except B2Error:
|
||||
if tar_uploaded:
|
||||
await self._cleanup_failed_upload(prefixed_tar_filename)
|
||||
raise
|
||||
|
||||
def _upload_metadata_file_sync(
|
||||
self, metadata_content: bytes, filename: str
|
||||
|
||||
@@ -174,6 +174,14 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
|
||||
)
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except exception.BadRequest as err:
|
||||
_LOGGER.error(
|
||||
"Backblaze B2 API rejected the request for Key ID '%s': %s",
|
||||
user_input[CONF_KEY_ID],
|
||||
err,
|
||||
)
|
||||
errors["base"] = "bad_request"
|
||||
placeholders["error_message"] = str(err)
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["b2sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["b2sdk==2.10.1"]
|
||||
"requirements": ["b2sdk==2.10.4"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_request": "The Backblaze B2 API rejected the request: {error_message}",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]",
|
||||
@@ -60,6 +61,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"bad_request": {
|
||||
"message": "The Backblaze B2 API rejected the request: {error_message}"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["cronsim==2.7", "securetar==2026.2.0"],
|
||||
"requirements": ["cronsim==2.7", "securetar==2026.4.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from securetar import (
|
||||
SecureTarFile,
|
||||
SecureTarReadError,
|
||||
SecureTarRootKeyContext,
|
||||
get_archive_max_ciphertext_size,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -431,7 +432,9 @@ class _CipherBackupStreamer:
|
||||
|
||||
def size(self) -> int:
|
||||
"""Return the maximum size of the decrypted or encrypted backup."""
|
||||
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
|
||||
return get_archive_max_ciphertext_size( # type: ignore[no-any-return]
|
||||
self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files()
|
||||
)
|
||||
|
||||
def _num_tar_files(self) -> int:
|
||||
"""Return the number of inner tar files."""
|
||||
|
||||
@@ -10,6 +10,7 @@ from bsblan import (
|
||||
BSBLAN,
|
||||
BSBLANAuthError,
|
||||
BSBLANConnectionError,
|
||||
BSBLANError,
|
||||
HotWaterConfig,
|
||||
HotWaterSchedule,
|
||||
HotWaterState,
|
||||
@@ -50,7 +51,7 @@ class BSBLanFastData:
|
||||
|
||||
state: State
|
||||
sensor: Sensor
|
||||
dhw: HotWaterState
|
||||
dhw: HotWaterState | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -111,7 +112,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
# This reduces response time significantly (~0.2s per parameter)
|
||||
state = await self.client.state(include=STATE_INCLUDE)
|
||||
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
|
||||
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
|
||||
|
||||
except BSBLANAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
@@ -126,6 +126,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
translation_placeholders={"host": host},
|
||||
) from err
|
||||
|
||||
# Fetch DHW state separately - device may not support hot water
|
||||
dhw: HotWaterState | None = None
|
||||
try:
|
||||
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
|
||||
except BSBLANError:
|
||||
# Preserve last known DHW state if available (entity may depend on it)
|
||||
if self.data:
|
||||
dhw = self.data.dhw
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) state not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
|
||||
return BSBLanFastData(
|
||||
state=state,
|
||||
sensor=sensor,
|
||||
@@ -159,13 +172,6 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
|
||||
dhw_schedule = await self.client.hot_water_schedule()
|
||||
|
||||
except AttributeError:
|
||||
# Device does not support DHW functionality
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
return BSBLanSlowData()
|
||||
except (BSBLANConnectionError, BSBLANAuthError) as err:
|
||||
# If config update fails, keep existing data
|
||||
LOGGER.debug(
|
||||
@@ -177,6 +183,13 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
return self.data
|
||||
# First fetch failed, return empty data
|
||||
return BSBLanSlowData()
|
||||
except BSBLANError, AttributeError:
|
||||
# Device does not support DHW functionality
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
return BSBLanSlowData()
|
||||
|
||||
return BSBLanSlowData(
|
||||
dhw_config=dhw_config,
|
||||
|
||||
@@ -22,7 +22,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"fast_coordinator_data": {
|
||||
"state": data.fast_coordinator.data.state.model_dump(),
|
||||
"sensor": data.fast_coordinator.data.sensor.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump()
|
||||
if data.fast_coordinator.data.dhw
|
||||
else None,
|
||||
},
|
||||
"static": data.static.model_dump() if data.static is not None else None,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -10,7 +13,7 @@ from homeassistant.helpers.device_registry import (
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import BSBLanData
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
|
||||
|
||||
@@ -22,7 +25,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
def __init__(self, coordinator: _T, data: BSBLanData) -> None:
|
||||
"""Initialize BSBLan entity with device info."""
|
||||
super().__init__(coordinator)
|
||||
host = coordinator.config_entry.data["host"]
|
||||
host = coordinator.config_entry.data[CONF_HOST]
|
||||
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
|
||||
mac = data.device.MAC
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
@@ -44,7 +48,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
else None
|
||||
),
|
||||
sw_version=data.device.version,
|
||||
configuration_url=f"http://{host}",
|
||||
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.1.3"],
|
||||
"requirements": ["python-bsblan==5.1.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bsblan import BSBLANError, SetHotWaterParam
|
||||
from bsblan import BSBLANError, HotWaterState, SetHotWaterParam
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
@@ -46,8 +46,10 @@ async def async_setup_entry(
|
||||
data = entry.runtime_data
|
||||
|
||||
# Only create water heater entity if DHW (Domestic Hot Water) is available
|
||||
# Check if we have any DHW-related data indicating water heater support
|
||||
dhw_data = data.fast_coordinator.data.dhw
|
||||
if dhw_data is None:
|
||||
# Device does not support DHW, skip water heater setup
|
||||
return
|
||||
if (
|
||||
dhw_data.operating_mode is None
|
||||
and dhw_data.nominal_setpoint is None
|
||||
@@ -107,11 +109,21 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
else:
|
||||
self._attr_max_temp = 65.0 # Default maximum
|
||||
|
||||
@property
|
||||
def _dhw(self) -> HotWaterState:
|
||||
"""Return DHW state data.
|
||||
|
||||
This entity is only created when DHW data is available.
|
||||
"""
|
||||
dhw = self.coordinator.data.dhw
|
||||
assert dhw is not None
|
||||
return dhw
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return current operation."""
|
||||
if (
|
||||
operating_mode := self.coordinator.data.dhw.operating_mode
|
||||
operating_mode := self._dhw.operating_mode
|
||||
) is None or operating_mode.value is None:
|
||||
return None
|
||||
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
|
||||
@@ -119,16 +131,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if (
|
||||
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
|
||||
) is None:
|
||||
if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None:
|
||||
return None
|
||||
return current_temp.value
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
|
||||
if (target_temp := self._dhw.nominal_setpoint) is None:
|
||||
return None
|
||||
return target_temp.value
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
await self.async_set_unique_id(str(user.player_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=user.name, data=user_input)
|
||||
return self.async_create_entry(
|
||||
title=user.name or user.username, data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -112,7 +112,7 @@ class ComelitAlarmEntity(
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
|
||||
if self._area.human_status == AlarmAreaState.UNKNOWN:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@@ -151,7 +151,7 @@ class ComelitAlarmEntity(
|
||||
if code != str(self.coordinator.api.device_pin):
|
||||
return
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[DISABLE]
|
||||
self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
|
||||
@@ -160,7 +160,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[AWAY]
|
||||
self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
|
||||
@@ -169,7 +169,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[HOME]
|
||||
self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
|
||||
@@ -178,7 +178,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[NIGHT]
|
||||
self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.2"]
|
||||
}
|
||||
|
||||
@@ -448,10 +448,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
if not attributes.get("MACAddress"):
|
||||
continue
|
||||
|
||||
wan_access_result = None
|
||||
if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None:
|
||||
wan_access_result = "granted" in wan_access
|
||||
else:
|
||||
wan_access_result = None
|
||||
# wan_access can be "granted", "denied", "unknown" or "error"
|
||||
if "granted" in wan_access:
|
||||
wan_access_result = True
|
||||
elif "denied" in wan_access:
|
||||
wan_access_result = False
|
||||
|
||||
hosts[attributes["MACAddress"]] = Device(
|
||||
name=attributes["HostName"],
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260325.6"]
|
||||
"requirements": ["home-assistant-frontend==20260325.7"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["afsapi==0.2.7"],
|
||||
"requirements": ["afsapi==0.3.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.93", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.94", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -68,8 +68,16 @@ PROGRAM_OPTIONS = {
|
||||
),
|
||||
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
|
||||
OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool,
|
||||
}.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ set_program_and_options:
|
||||
- cooking_common_program_hood_automatic
|
||||
- cooking_common_program_hood_venting
|
||||
- cooking_common_program_hood_delayed_shut_off
|
||||
- cooking_oven_program_heating_mode_3_d_heating
|
||||
- cooking_oven_program_heating_mode_3_d_hot_air
|
||||
- cooking_oven_program_heating_mode_air_fry
|
||||
- cooking_oven_program_heating_mode_grill_large_area
|
||||
- cooking_oven_program_heating_mode_grill_small_area
|
||||
@@ -210,6 +210,7 @@ set_program_and_options:
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode:
|
||||
example: heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -222,7 +223,7 @@ set_program_and_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
consumer_products_cleaning_robot_option_reference_map_id:
|
||||
example: consumer_products_cleaning_robot_enum_type_available_maps_map1
|
||||
example: consumer_products_cleaning_robot_enum_type_available_maps_map_1
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -230,9 +231,9 @@ set_program_and_options:
|
||||
translation_key: available_maps
|
||||
options:
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_temp_map
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map1
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map2
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map3
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map_1
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map_2
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map_3
|
||||
consumer_products_cleaning_robot_option_cleaning_mode:
|
||||
example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
|
||||
required: false
|
||||
@@ -310,7 +311,7 @@ set_program_and_options:
|
||||
- consumer_products_coffee_maker_enum_type_coffee_temperature_94_c
|
||||
- consumer_products_coffee_maker_enum_type_coffee_temperature_95_c
|
||||
- consumer_products_coffee_maker_enum_type_coffee_temperature_96_c
|
||||
consumer_products_coffee_maker_option_bean_container:
|
||||
consumer_products_coffee_maker_option_bean_container_selection:
|
||||
example: consumer_products_coffee_maker_enum_type_bean_container_selection_right
|
||||
required: false
|
||||
selector:
|
||||
@@ -468,8 +469,8 @@ set_program_and_options:
|
||||
hood_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
cooking_hood_option_venting_level:
|
||||
example: cooking_hood_enum_type_stage_fan_stage01
|
||||
cooking_common_option_hood_venting_level:
|
||||
example: cooking_hood_enum_type_stage_fan_stage_01
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -482,8 +483,8 @@ set_program_and_options:
|
||||
- cooking_hood_enum_type_stage_fan_stage_03
|
||||
- cooking_hood_enum_type_stage_fan_stage_04
|
||||
- cooking_hood_enum_type_stage_fan_stage_05
|
||||
cooking_hood_option_intensive_level:
|
||||
example: cooking_hood_enum_type_intensive_stage_intensive_stage1
|
||||
cooking_common_option_hood_intensive_level:
|
||||
example: cooking_hood_enum_type_intensive_stage_intensive_stage_1
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -491,8 +492,8 @@ set_program_and_options:
|
||||
translation_key: intensive_level
|
||||
options:
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage_off
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage1
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage2
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage_1
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage_2
|
||||
oven_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
@@ -567,7 +568,7 @@ set_program_and_options:
|
||||
- laundry_care_washer_enum_type_temperature_ul_hot
|
||||
- laundry_care_washer_enum_type_temperature_ul_extra_hot
|
||||
laundry_care_washer_option_spin_speed:
|
||||
example: laundry_care_washer_enum_type_spin_speed_r_p_m800
|
||||
example: laundry_care_washer_enum_type_spin_speed_r_p_m_800
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -611,12 +612,12 @@ set_program_and_options:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_i_dos1_active:
|
||||
laundry_care_washer_option_i_dos_1_active:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_i_dos2_active:
|
||||
laundry_care_washer_option_i_dos_2_active:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
@@ -656,7 +657,7 @@ set_program_and_options:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_vario_perfect:
|
||||
laundry_care_common_option_vario_perfect:
|
||||
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
|
||||
"cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
@@ -431,7 +431,7 @@
|
||||
}
|
||||
},
|
||||
"bean_container": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container_selection::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]"
|
||||
@@ -484,9 +484,9 @@
|
||||
"current_map": {
|
||||
"name": "Current map",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
|
||||
}
|
||||
},
|
||||
@@ -557,19 +557,19 @@
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_intensive_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_1%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_2%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]"
|
||||
}
|
||||
},
|
||||
"reference_map_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
|
||||
}
|
||||
},
|
||||
@@ -620,7 +620,7 @@
|
||||
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
|
||||
"cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
@@ -786,7 +786,7 @@
|
||||
}
|
||||
},
|
||||
"vario_perfect": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_vario_perfect::name%]",
|
||||
"state": {
|
||||
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
|
||||
@@ -794,7 +794,7 @@
|
||||
}
|
||||
},
|
||||
"venting_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_venting_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]",
|
||||
@@ -1272,10 +1272,10 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
|
||||
},
|
||||
"i_dos1_active": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_1_active::name%]"
|
||||
},
|
||||
"i_dos2_active": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_2_active::name%]"
|
||||
},
|
||||
"intensiv_zone": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
|
||||
@@ -1458,9 +1458,9 @@
|
||||
},
|
||||
"available_maps": {
|
||||
"options": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "Map 1",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "Map 2",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "Map 3",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map"
|
||||
}
|
||||
},
|
||||
@@ -1584,8 +1584,8 @@
|
||||
},
|
||||
"intensive_level": {
|
||||
"options": {
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_1": "Intensive stage 1",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_2": "Intensive stage 2",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off"
|
||||
}
|
||||
},
|
||||
@@ -1629,7 +1629,7 @@
|
||||
"cooking_common_program_hood_automatic": "Automatic",
|
||||
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
|
||||
"cooking_common_program_hood_venting": "Venting",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "3D heating",
|
||||
"cooking_oven_program_heating_mode_3_d_hot_air": "3D hot air",
|
||||
"cooking_oven_program_heating_mode_air_fry": "Air fry",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
|
||||
@@ -1892,7 +1892,7 @@
|
||||
"description": "Describes the amount of coffee beans used in a coffee machine program.",
|
||||
"name": "Bean amount"
|
||||
},
|
||||
"consumer_products_coffee_maker_option_bean_container": {
|
||||
"consumer_products_coffee_maker_option_bean_container_selection": {
|
||||
"description": "Defines the preferred bean container.",
|
||||
"name": "Bean container"
|
||||
},
|
||||
@@ -1920,11 +1920,11 @@
|
||||
"description": "Defines if double dispensing is enabled.",
|
||||
"name": "Multiple beverages"
|
||||
},
|
||||
"cooking_hood_option_intensive_level": {
|
||||
"cooking_common_option_hood_intensive_level": {
|
||||
"description": "Defines the intensive setting.",
|
||||
"name": "Intensive level"
|
||||
},
|
||||
"cooking_hood_option_venting_level": {
|
||||
"cooking_common_option_hood_venting_level": {
|
||||
"description": "Defines the required fan setting.",
|
||||
"name": "Venting level"
|
||||
},
|
||||
@@ -1992,15 +1992,19 @@
|
||||
"description": "Defines if the silent mode is activated.",
|
||||
"name": "Silent mode"
|
||||
},
|
||||
"laundry_care_common_option_vario_perfect": {
|
||||
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
|
||||
"name": "Vario perfect"
|
||||
},
|
||||
"laundry_care_dryer_option_drying_target": {
|
||||
"description": "Describes the drying target for a dryer program.",
|
||||
"name": "Drying target"
|
||||
},
|
||||
"laundry_care_washer_option_i_dos1_active": {
|
||||
"laundry_care_washer_option_i_dos_1_active": {
|
||||
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)",
|
||||
"name": "i-Dos 1 Active"
|
||||
},
|
||||
"laundry_care_washer_option_i_dos2_active": {
|
||||
"laundry_care_washer_option_i_dos_2_active": {
|
||||
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)",
|
||||
"name": "i-Dos 2 Active"
|
||||
},
|
||||
@@ -2044,10 +2048,6 @@
|
||||
"description": "Defines the temperature of the washing program.",
|
||||
"name": "Temperature"
|
||||
},
|
||||
"laundry_care_washer_option_vario_perfect": {
|
||||
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
|
||||
"name": "Vario perfect"
|
||||
},
|
||||
"laundry_care_washer_option_water_plus": {
|
||||
"description": "Defines if the water plus option is activated.",
|
||||
"name": "Water +"
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"requirements": ["aiohue==4.8.0"],
|
||||
"requirements": ["aiohue==4.8.1"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["incomfortclient"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["incomfort-client==0.6.12"]
|
||||
"requirements": ["incomfort-client==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -92,11 +92,13 @@
|
||||
"central_heating": "Central heating",
|
||||
"central_heating_low": "Central heating low",
|
||||
"central_heating_rf": "Central heating rf",
|
||||
"central_heating_wait": "Central heating waiting",
|
||||
"cv_temperature_too_high_e1": "Temperature too high",
|
||||
"flame_detection_fault_e6": "Flame detection fault",
|
||||
"frost": "Frost protection",
|
||||
"gas_valve_relay_faulty_e29": "Gas valve relay faulty",
|
||||
"gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]",
|
||||
"hp_error_recovery": "Heat pump error recovery",
|
||||
"incorrect_fan_speed_e8": "Incorrect fan speed",
|
||||
"no_flame_signal_e4": "No flame signal",
|
||||
"off": "[%key:common::state::off%]",
|
||||
@@ -120,6 +122,7 @@
|
||||
"service": "Service",
|
||||
"shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"starting_ch": "Starting central heating",
|
||||
"tapwater": "Tap water",
|
||||
"tapwater_int": "Tap water internal",
|
||||
"unknown": "Unknown"
|
||||
|
||||
@@ -143,7 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
|
||||
try:
|
||||
fireplace: UnifiedFireplace = (
|
||||
await UnifiedFireplace.build_fireplace_from_common(
|
||||
_construct_common_data(entry)
|
||||
_construct_common_data(entry),
|
||||
polling_enabled=False,
|
||||
)
|
||||
)
|
||||
LOGGER.debug("Waiting for Fireplace to Initialize")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import aiohttp
|
||||
from intellifire4py import UnifiedFireplace
|
||||
from intellifire4py.control import IntelliFireController
|
||||
from intellifire4py.model import IntelliFirePollData
|
||||
@@ -11,8 +12,9 @@ from intellifire4py.read import IntelliFireDataProvider
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -52,6 +54,14 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData
|
||||
return self.fireplace.control_api
|
||||
|
||||
async def _async_update_data(self) -> IntelliFirePollData:
|
||||
try:
|
||||
await self.fireplace.perform_poll()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if err.status == 403:
|
||||
raise ConfigEntryAuthFailed("Authentication failed") from err
|
||||
raise UpdateFailed(f"Error communicating with fireplace: {err}") from err
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise UpdateFailed(f"Error communicating with fireplace: {err}") from err
|
||||
return self.fireplace.data
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==2.0.3"]
|
||||
"requirements": ["pyjvcprojector==2.0.5"]
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class LGDevice(MediaPlayerEntity):
|
||||
"""Representation of an LG soundbar device."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_state = MediaPlayerState.OFF
|
||||
_attr_state = MediaPlayerState.ON # Default to ON to ensure compatibility with models that don't send a powerstatus message
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylitterbot"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylitterbot==2025.2.0"]
|
||||
"requirements": ["pylitterbot==2025.2.1"]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output
|
||||
from pylutron import (
|
||||
Button,
|
||||
Keypad,
|
||||
Led,
|
||||
Lutron,
|
||||
LutronException,
|
||||
OccupancyGroup,
|
||||
Output,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -57,8 +66,12 @@ async def async_setup_entry(
|
||||
pwd = config_entry.data[CONF_PASSWORD]
|
||||
|
||||
lutron_client = Lutron(host, uid, pwd)
|
||||
await hass.async_add_executor_job(lutron_client.load_xml_db)
|
||||
lutron_client.connect()
|
||||
try:
|
||||
await hass.async_add_executor_job(lutron_client.load_xml_db)
|
||||
lutron_client.connect()
|
||||
except LutronException as ex:
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Lutron repeater: {ex}") from ex
|
||||
|
||||
_LOGGER.debug("Connected to main repeater at %s", host)
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.4.0"],
|
||||
"requirements": ["pylutron==0.4.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
(0x1209, 0x8027),
|
||||
(0x1209, 0x8028),
|
||||
(0x1209, 0x8029),
|
||||
(0x138C, 0x0101),
|
||||
}
|
||||
|
||||
SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
@@ -156,6 +157,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
(0x1209, 0x8028),
|
||||
(0x1209, 0x8029),
|
||||
(0x131A, 0x1000),
|
||||
(0x138C, 0x0101),
|
||||
}
|
||||
|
||||
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
|
||||
|
||||
@@ -19,9 +19,13 @@ LIGHT = "light"
|
||||
LIGHT_ON = 1
|
||||
LIGHT_OFF = 2
|
||||
|
||||
# API "no reading" sentinels. Most temperatures use centidegrees (-32768 -> -327.68 °C).
|
||||
# Some devices report the int16 minimum already in degrees after scaling (-3276800 raw -> -32768 °C).
|
||||
DISABLED_TEMP_ENTITIES = (
|
||||
-32768 / 100,
|
||||
-32766 / 100,
|
||||
-32768.0,
|
||||
-32766.0,
|
||||
)
|
||||
|
||||
|
||||
@@ -494,7 +498,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
intensive = 1, 26, 205
|
||||
maintenance = 2, 27, 214
|
||||
eco = 3, 22, 28, 200
|
||||
automatic = 6, 7, 31, 32, 202
|
||||
automatic = 6, 7, 31, 32, 201, 202
|
||||
solar_save = 9, 34
|
||||
gentle = 10, 35, 210
|
||||
extra_quiet = 11, 36, 207
|
||||
|
||||
@@ -93,7 +93,14 @@ def _convert_temperature(
|
||||
"""Convert temperature object to readable value."""
|
||||
if index >= len(value_list):
|
||||
return None
|
||||
raw_value = cast(int, value_list[index].temperature) / 100.0
|
||||
raw = value_list[index].temperature
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
raw_centi = int(raw)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
raw_value = raw_centi / 100.0
|
||||
if raw_value in DISABLED_TEMP_ENTITIES:
|
||||
return None
|
||||
return raw_value
|
||||
@@ -639,6 +646,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition[MieleDevice], ...]] = (
|
||||
MieleAppliance.OVEN,
|
||||
MieleAppliance.OVEN_MICROWAVE,
|
||||
MieleAppliance.STEAM_OVEN_COMBI,
|
||||
MieleAppliance.STEAM_OVEN_MK2,
|
||||
),
|
||||
description=MieleSensorDescription(
|
||||
key="state_core_temperature",
|
||||
@@ -840,9 +848,9 @@ async def async_setup_entry(
|
||||
and definition.description.value_fn(device) is None
|
||||
and definition.description.zone != 1
|
||||
):
|
||||
# all appliances supporting temperature have at least zone 1, for other zones
|
||||
# don't create entity if API signals that datapoint is disabled, unless the sensor
|
||||
# already appeared in the past (= it provided a valid value)
|
||||
# Optional temperature datapoints (extra fridge zones, oven food probe): only
|
||||
# create the entity after the API first reports a valid reading, then keep it
|
||||
# so state can return to unknown when the datapoint is inactive.
|
||||
return _is_entity_registered(unique_id)
|
||||
if (
|
||||
definition.description.key == "state_plate_step"
|
||||
|
||||
@@ -146,7 +146,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
|
||||
|
||||
_fixed_color_mode: ColorMode | str | None = None
|
||||
_flash_times: dict[str, int | None]
|
||||
_topic: dict[str, str | None]
|
||||
_optimistic: bool
|
||||
@@ -190,6 +189,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
self._attr_supported_features |= (
|
||||
config[CONF_TRANSITION] and LightEntityFeature.TRANSITION
|
||||
)
|
||||
self._attr_color_mode = ColorMode.UNKNOWN
|
||||
if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES):
|
||||
self._attr_supported_color_modes = supported_color_modes
|
||||
if self.supported_color_modes and len(self.supported_color_modes) == 1:
|
||||
|
||||
@@ -104,12 +104,8 @@ async def async_setup_entry(
|
||||
def _create_entity(device: dict) -> MyNeoSelect:
|
||||
"""Create a select entity for a device."""
|
||||
if device["model"] == "EWS":
|
||||
# According to the MyNeomitis API, EWS "relais" devices expose a "relayMode"
|
||||
# field in their state, while "pilote" devices do not. We therefore use the
|
||||
# presence of "relayMode" as an explicit heuristic to distinguish relais
|
||||
# from pilote devices. If the upstream API changes this behavior, this
|
||||
# detection logic must be revisited.
|
||||
if "relayMode" in device.get("state", {}):
|
||||
state = device.get("state") or {}
|
||||
if state.get("deviceType") == 0:
|
||||
description = SELECT_TYPES["relais"]
|
||||
else:
|
||||
description = SELECT_TYPES["pilote"]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoauth", "pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.3"]
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"]
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ def _validate_input(data: dict[str, Any]) -> None:
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
nzbget_api = NZBGetAPI(
|
||||
data[CONF_HOST],
|
||||
data.get(CONF_USERNAME),
|
||||
data.get(CONF_PASSWORD),
|
||||
data[CONF_SSL],
|
||||
data[CONF_VERIFY_SSL],
|
||||
data[CONF_PORT],
|
||||
host=data[CONF_HOST],
|
||||
username=data.get(CONF_USERNAME),
|
||||
password=data.get(CONF_PASSWORD),
|
||||
secure=data[CONF_SSL],
|
||||
verify_certificate=data[CONF_VERIFY_SSL],
|
||||
port=data[CONF_PORT],
|
||||
)
|
||||
|
||||
nzbget_api.version()
|
||||
|
||||
@@ -35,12 +35,12 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
) -> None:
|
||||
"""Initialize global NZBGet data updater."""
|
||||
self.nzbget = NZBGetAPI(
|
||||
config_entry.data[CONF_HOST],
|
||||
config_entry.data.get(CONF_USERNAME),
|
||||
config_entry.data.get(CONF_PASSWORD),
|
||||
config_entry.data[CONF_SSL],
|
||||
config_entry.data[CONF_VERIFY_SSL],
|
||||
config_entry.data[CONF_PORT],
|
||||
host=config_entry.data[CONF_HOST],
|
||||
username=config_entry.data.get(CONF_USERNAME),
|
||||
password=config_entry.data.get(CONF_PASSWORD),
|
||||
secure=config_entry.data[CONF_SSL],
|
||||
verify_certificate=config_entry.data[CONF_VERIFY_SSL],
|
||||
port=config_entry.data[CONF_PORT],
|
||||
)
|
||||
|
||||
self._completed_downloads_init = False
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["oasatelematics"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["oasatelematics==0.3"]
|
||||
"requirements": ["oasatelematics==0.4"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.0"]
|
||||
"requirements": ["opower==0.18.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["python_picnic_api2"],
|
||||
"requirements": ["python-picnic-api2==1.3.1"]
|
||||
"requirements": ["python-picnic-api2==1.3.4"]
|
||||
}
|
||||
|
||||
@@ -168,15 +168,34 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
docker_version,
|
||||
docker_info,
|
||||
docker_system_df,
|
||||
stacks,
|
||||
) = await asyncio.gather(
|
||||
self.portainer.get_containers(endpoint.id),
|
||||
self.portainer.docker_version(endpoint.id),
|
||||
self.portainer.docker_info(endpoint.id),
|
||||
self.portainer.docker_system_df(endpoint.id),
|
||||
self.portainer.get_stacks(endpoint.id),
|
||||
)
|
||||
|
||||
stack_requests = [self.portainer.get_stacks(endpoint_id=endpoint.id)]
|
||||
swarm_id = (
|
||||
docker_info.swarm.cluster.get("ID")
|
||||
if docker_info.swarm
|
||||
and docker_info.swarm.control_available
|
||||
and docker_info.swarm.cluster
|
||||
else None
|
||||
)
|
||||
if swarm_id:
|
||||
stack_requests.append(
|
||||
self.portainer.get_stacks(
|
||||
endpoint_id=endpoint.id, swarm_id=swarm_id
|
||||
)
|
||||
)
|
||||
|
||||
stacks = [
|
||||
stack
|
||||
for result in await asyncio.gather(*stack_requests)
|
||||
for stack in result
|
||||
]
|
||||
|
||||
prev_endpoint = self.data.get(endpoint.id) if self.data else None
|
||||
container_map: dict[str, PortainerContainerData] = {}
|
||||
stack_map: dict[str, PortainerStackData] = {
|
||||
|
||||
@@ -189,6 +189,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) ->
|
||||
# Migration for additional configuration options added to support API tokens
|
||||
if entry.version < 3:
|
||||
data = dict(entry.data)
|
||||
# If CONF_REALM wasn't there yet, extract from username
|
||||
if CONF_REALM not in data:
|
||||
data[CONF_REALM] = DEFAULT_REALM
|
||||
if "@" in data.get(CONF_USERNAME, ""):
|
||||
username, realm = data[CONF_USERNAME].split("@", 1)
|
||||
data[CONF_USERNAME] = username
|
||||
data[CONF_REALM] = realm.lower()
|
||||
|
||||
realm = data[CONF_REALM].lower()
|
||||
|
||||
# If the realm is one of the base providers, set the provider to match the realm.
|
||||
|
||||
@@ -28,14 +28,17 @@ from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
|
||||
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
|
||||
from .helpers import is_granted
|
||||
|
||||
NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox node button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str], None]
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER
|
||||
permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER
|
||||
permission_raise: str = "no_permission_node_power"
|
||||
permission_target: str = "nodes"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -44,7 +47,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str, int], None]
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER
|
||||
permission_raise: str = "no_permission_vm_lxc_power"
|
||||
permission_raise: str = NO_PERM_VM_LXC_POWER
|
||||
permission_target: str = "vms"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -53,7 +57,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str, int], None]
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER
|
||||
permission_raise: str = "no_permission_vm_lxc_power"
|
||||
permission_raise: str = NO_PERM_VM_LXC_POWER
|
||||
permission_target: str = "vms"
|
||||
|
||||
|
||||
NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
|
||||
@@ -76,6 +81,9 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="start_all",
|
||||
translation_key="start_all",
|
||||
permission=ProxmoxPermission.POWER,
|
||||
permission_raise=NO_PERM_VM_LXC_POWER,
|
||||
permission_target="vms",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).startall.post(),
|
||||
@@ -84,6 +92,9 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="stop_all",
|
||||
translation_key="stop_all",
|
||||
permission=ProxmoxPermission.POWER,
|
||||
permission_raise=NO_PERM_VM_LXC_POWER,
|
||||
permission_target="vms",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).stopall.post(),
|
||||
@@ -92,6 +103,9 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="suspend_all",
|
||||
translation_key="suspend_all",
|
||||
permission=ProxmoxPermission.POWER,
|
||||
permission_raise=NO_PERM_VM_LXC_POWER,
|
||||
permission_target="vms",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).suspendall.post(),
|
||||
@@ -327,7 +341,7 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
|
||||
node_id = self._node_data.node["node"]
|
||||
if not is_granted(
|
||||
self.coordinator.permissions,
|
||||
p_type="nodes",
|
||||
p_type=self.entity_description.permission_target,
|
||||
p_id=node_id,
|
||||
permission=self.entity_description.permission,
|
||||
):
|
||||
@@ -352,7 +366,7 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
|
||||
vmid = self.vm_data["vmid"]
|
||||
if not is_granted(
|
||||
self.coordinator.permissions,
|
||||
p_type="vms",
|
||||
p_type=self.entity_description.permission_target,
|
||||
p_id=vmid,
|
||||
permission=self.entity_description.permission,
|
||||
):
|
||||
@@ -379,7 +393,7 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
|
||||
# Container power actions fall under vms
|
||||
if not is_granted(
|
||||
self.coordinator.permissions,
|
||||
p_type="vms",
|
||||
p_type=self.entity_description.permission_target,
|
||||
p_id=vmid,
|
||||
permission=self.entity_description.permission,
|
||||
):
|
||||
|
||||
@@ -41,3 +41,4 @@ class ProxmoxPermission(StrEnum):
|
||||
|
||||
POWER = "VM.PowerMgmt"
|
||||
SNAPSHOT = "VM.Snapshot"
|
||||
SYSPOWER = "Sys.PowerMgmt"
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
"name": "Reset"
|
||||
},
|
||||
"shutdown": {
|
||||
"name": "Shutdown"
|
||||
"name": "Shut down"
|
||||
},
|
||||
"snapshot_create": {
|
||||
"name": "Create snapshot"
|
||||
@@ -313,7 +313,7 @@
|
||||
"message": "No active nodes were found on the Proxmox VE server."
|
||||
},
|
||||
"no_permission_node_power": {
|
||||
"message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again."
|
||||
"message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again."
|
||||
},
|
||||
"no_permission_snapshot": {
|
||||
"message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again."
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiopvpc"],
|
||||
"requirements": ["aiopvpc==4.2.2"]
|
||||
"requirements": ["aiopvpc==4.3.1"]
|
||||
}
|
||||
|
||||
@@ -79,8 +79,10 @@ class QbusLight(QbusEntity, LightEntity):
|
||||
await self._async_publish_output_state(state)
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttAnalogState) -> None:
|
||||
percentage = round(state.read_percentage())
|
||||
self._set_state(percentage)
|
||||
percentage = state.read_percentage()
|
||||
|
||||
if percentage is not None:
|
||||
self._set_state(round(percentage))
|
||||
|
||||
def _set_state(self, percentage: int) -> None:
|
||||
self._attr_is_on = percentage > 0
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"cloudapp/QBUSMQTTGW/+/state"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["qbusmqttapi==1.4.2"]
|
||||
"requirements": ["qbusmqttapi==1.4.3"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,14 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_TYPE
|
||||
|
||||
from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN
|
||||
from .const import (
|
||||
CONF_CLOUD_ID,
|
||||
CONF_HARDWARE_ADDRESS,
|
||||
CONF_INSTALL_CODE,
|
||||
DOMAIN,
|
||||
TYPE_EAGLE_100,
|
||||
TYPE_EAGLE_200,
|
||||
)
|
||||
from .data import CannotConnect, InvalidAuth, async_get_type
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -63,11 +70,32 @@ class RainforestEagleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
user_input[CONF_TYPE] = eagle_type
|
||||
user_input[CONF_HARDWARE_ADDRESS] = hardware_address
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_CLOUD_ID], data=user_input
|
||||
)
|
||||
# Verify it is a known device, first
|
||||
if not eagle_type:
|
||||
errors["base"] = "unknown_device_type"
|
||||
elif eagle_type == TYPE_EAGLE_100:
|
||||
user_input[CONF_TYPE] = eagle_type
|
||||
|
||||
# For EAGLE-100, there is no hardware address to select, so set it to None and move on
|
||||
user_input[CONF_HARDWARE_ADDRESS] = None
|
||||
elif eagle_type == TYPE_EAGLE_200:
|
||||
user_input[CONF_TYPE] = eagle_type
|
||||
|
||||
# For EAGLE-200, a connected meter's hardware address is required to create the entry
|
||||
if not hardware_address:
|
||||
# hardware_address will be None if there are no meters at all or if none are currently Connected
|
||||
errors["base"] = "no_meters_connected"
|
||||
else:
|
||||
user_input[CONF_HARDWARE_ADDRESS] = hardware_address
|
||||
else:
|
||||
# This is a device that isn't supported, yet, but was detected by async_get_type
|
||||
errors["base"] = "unsupported_device_type"
|
||||
|
||||
# All information gathering is done, so if there are no errors at this point, create the entry
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_CLOUD_ID], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=create_schema(user_input), errors=errors
|
||||
|
||||
@@ -34,7 +34,7 @@ class InvalidAuth(RainforestError):
|
||||
|
||||
async def async_get_type(hass, cloud_id, install_code, host):
|
||||
"""Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200."""
|
||||
# For EAGLE-200, fetch the hardware address of the meter too.
|
||||
# For EAGLE-200, fetch the hardware address of the first connected meter, too.
|
||||
hub = aioeagle.EagleHub(
|
||||
aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host
|
||||
)
|
||||
@@ -50,8 +50,17 @@ async def async_get_type(hass, cloud_id, install_code, host):
|
||||
|
||||
if meters is not None:
|
||||
if meters:
|
||||
hardware_address = meters[0].hardware_address
|
||||
# If there is at least one meter, use the first one with a connection status of "Connected"
|
||||
hardware_address = next(
|
||||
(
|
||||
m.hardware_address
|
||||
for m in meters
|
||||
if getattr(m, "connection_status", None) == "Connected"
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# If there are no meters (empty list, since None was already checked for), set the hardware address to None
|
||||
hardware_address = None
|
||||
|
||||
return TYPE_EAGLE_200, hardware_address
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"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%]"
|
||||
"no_meters_connected": "No meters are currently connected. Ensure your meter is connected and try again.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_device_type": "Unable to determine the type of Rainforest Eagle device. Please ensure your device is supported.",
|
||||
"unsupported_device_type": "This type of Rainforest Eagle device is not supported."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -128,8 +128,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
self._device = self._get_coordinator_data().get_video_device(
|
||||
self._device.device_api_id
|
||||
)
|
||||
|
||||
history_data = self._device.last_history
|
||||
if history_data:
|
||||
if history_data and self._device.has_subscription:
|
||||
self._last_event = history_data[0]
|
||||
# will call async_update to update the attributes and get the
|
||||
# video url from the api
|
||||
@@ -154,13 +155,16 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
# For live_view cameras, get a fresh snapshot
|
||||
if self.entity_description.key == "live_view":
|
||||
return await self._async_get_fresh_snapshot()
|
||||
if self._video_url is None:
|
||||
if not self._device.has_subscription:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_subscription",
|
||||
)
|
||||
return None
|
||||
|
||||
# For last_recording cameras, use the cached video frame
|
||||
key = (width, height)
|
||||
if not (image := self._images.get(key)) and self._video_url is not None:
|
||||
if not (image := self._images.get(key)):
|
||||
image = await ffmpeg.async_get_image(
|
||||
self.hass,
|
||||
self._video_url,
|
||||
@@ -173,11 +177,6 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
|
||||
return image
|
||||
|
||||
@exception_wrap
|
||||
async def _async_get_fresh_snapshot(self) -> bytes | None:
|
||||
"""Get a fresh snapshot from the camera."""
|
||||
return await self._device.async_get_snapshot()
|
||||
|
||||
async def handle_async_mjpeg_stream(
|
||||
self, request: web.Request
|
||||
) -> web.StreamResponse | None:
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
"api_timeout": {
|
||||
"message": "Timeout communicating with Ring API"
|
||||
},
|
||||
"no_subscription": {
|
||||
"message": "Ring Protect subscription required for snapshots"
|
||||
},
|
||||
"sdp_m_line_index_required": {
|
||||
"message": "Error negotiating stream for {device}"
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/risco",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyrisco"],
|
||||
"requirements": ["pyrisco==0.6.7"]
|
||||
"requirements": ["pyrisco==0.6.8"]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ from homeassistant.components.vacuum import (
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceNotSupported,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -484,6 +488,18 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
async def get_maps(self) -> ServiceResponse:
|
||||
"""Get map information such as map id and room ids."""
|
||||
raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id)
|
||||
|
||||
async def get_vacuum_current_position(self) -> ServiceResponse:
|
||||
"""Get the current position of the vacuum from the map."""
|
||||
raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id)
|
||||
|
||||
async def async_set_vacuum_goto_position(self, x: int, y: int) -> None:
|
||||
"""Set the vacuum to go to a specific position."""
|
||||
raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id)
|
||||
|
||||
|
||||
class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity):
|
||||
"""Representation of a Roborock Q10 vacuum."""
|
||||
@@ -654,3 +670,15 @@ class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity):
|
||||
"command": command,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def get_maps(self) -> ServiceResponse:
|
||||
"""Get map information such as map id and room ids."""
|
||||
raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id)
|
||||
|
||||
async def get_vacuum_current_position(self) -> ServiceResponse:
|
||||
"""Get the current position of the vacuum from the map."""
|
||||
raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id)
|
||||
|
||||
async def async_set_vacuum_goto_position(self, x: int, y: int) -> None:
|
||||
"""Set the vacuum to go to a specific position."""
|
||||
raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id)
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -31,7 +31,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up 17Track from a config entry."""
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
session = async_create_clientsession(hass)
|
||||
client = SeventeenTrackClient(session=session)
|
||||
|
||||
try:
|
||||
|
||||
@@ -99,5 +99,5 @@ class SeventeenTrackConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@callback
|
||||
def _get_client(self):
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
session = aiohttp_client.async_create_clientsession(self.hass)
|
||||
return SeventeenTrackClient(session=session)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/starlink",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["starlink-grpc-core==1.2.4"]
|
||||
"requirements": ["starlink-grpc-core==1.2.5"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from switchbot_api import (
|
||||
SwitchBotAPI,
|
||||
SwitchBotAuthenticationError,
|
||||
SwitchBotConnectionError,
|
||||
SwitchBotDeviceOfflineError,
|
||||
)
|
||||
|
||||
from homeassistant.components import webhook
|
||||
@@ -202,7 +203,7 @@ async def make_device_data(
|
||||
|
||||
if isinstance(device, Device) and device.device_type == "Bot":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
hass, entry, api, device, coordinators_by_id, True
|
||||
)
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
if coordinator.data is not None:
|
||||
@@ -405,42 +406,49 @@ async def _initialize_webhook(
|
||||
hass,
|
||||
entry.data[CONF_WEBHOOK_ID],
|
||||
)
|
||||
|
||||
# check if webhook is configured in switchbot cloud
|
||||
check_webhook_result = None
|
||||
with contextlib.suppress(Exception):
|
||||
check_webhook_result = await api.get_webook_configuration()
|
||||
|
||||
actual_webhook_urls = (
|
||||
check_webhook_result["urls"]
|
||||
if check_webhook_result and "urls" in check_webhook_result
|
||||
else []
|
||||
)
|
||||
need_add_webhook = (
|
||||
len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls
|
||||
)
|
||||
need_clean_previous_webhook = (
|
||||
len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls
|
||||
)
|
||||
try:
|
||||
check_webhook_result = None
|
||||
with contextlib.suppress(Exception):
|
||||
check_webhook_result = await api.get_webook_configuration()
|
||||
|
||||
if need_clean_previous_webhook:
|
||||
# it seems is impossible to register multiple webhook.
|
||||
# So, if webhook already exists, we delete it
|
||||
await api.delete_webhook(actual_webhook_urls[0])
|
||||
_LOGGER.debug(
|
||||
"Deleted previous Switchbot cloud webhook url: %s",
|
||||
actual_webhook_urls[0],
|
||||
actual_webhook_urls = (
|
||||
check_webhook_result["urls"]
|
||||
if check_webhook_result and "urls" in check_webhook_result
|
||||
else []
|
||||
)
|
||||
need_add_webhook = (
|
||||
len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls
|
||||
)
|
||||
need_clean_previous_webhook = (
|
||||
len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls
|
||||
)
|
||||
|
||||
if need_add_webhook:
|
||||
# call api for register webhookurl
|
||||
await api.setup_webhook(webhook_url)
|
||||
_LOGGER.debug("Registered Switchbot cloud webhook at hass: %s", webhook_url)
|
||||
if need_clean_previous_webhook:
|
||||
# it seems is impossible to register multiple webhook.
|
||||
# So, if webhook already exists, we delete it
|
||||
await api.delete_webhook(actual_webhook_urls[0])
|
||||
_LOGGER.debug(
|
||||
"Deleted previous Switchbot cloud webhook url: %s",
|
||||
actual_webhook_urls[0],
|
||||
)
|
||||
|
||||
for coordinator in coordinators_by_id.values():
|
||||
coordinator.webhook_subscription_listener(True)
|
||||
if need_add_webhook:
|
||||
# call api for register webhookurl
|
||||
await api.setup_webhook(webhook_url)
|
||||
_LOGGER.debug(
|
||||
"Registered Switchbot cloud webhook at hass: %s", webhook_url
|
||||
)
|
||||
|
||||
_LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url)
|
||||
for coordinator in coordinators_by_id.values():
|
||||
coordinator.webhook_subscription_listener(True)
|
||||
|
||||
_LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url)
|
||||
except SwitchBotDeviceOfflineError as e:
|
||||
_LOGGER.error("Failed to connect Switchbot cloud device: %s", e)
|
||||
except SwitchBotConnectionError as e:
|
||||
_LOGGER.error("Failed to connect Switchbot cloud device: %s", e)
|
||||
|
||||
|
||||
def _create_handle_webhook(
|
||||
|
||||
@@ -23,7 +23,11 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util, ssl as ssl_util
|
||||
|
||||
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
from .coordinator import (
|
||||
TibberDataAPICoordinator,
|
||||
TibberDataCoordinator,
|
||||
TibberPriceCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
|
||||
@@ -39,6 +43,8 @@ class TibberRuntimeData:
|
||||
|
||||
session: OAuth2Session
|
||||
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
|
||||
data_coordinator: TibberDataCoordinator | None = field(default=None)
|
||||
price_coordinator: TibberPriceCoordinator | None = field(default=None)
|
||||
_client: tibber.Tibber | None = None
|
||||
|
||||
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
|
||||
@@ -55,7 +61,7 @@ class TibberRuntimeData:
|
||||
time_zone=dt_util.get_default_time_zone(),
|
||||
ssl=ssl_util.get_default_context(),
|
||||
)
|
||||
self._client.set_access_token(access_token)
|
||||
await self._client.set_access_token(access_token)
|
||||
return self._client
|
||||
|
||||
|
||||
@@ -124,6 +130,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
|
||||
except tibber.FatalHttpExceptionError as err:
|
||||
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
|
||||
|
||||
if tibber_connection.get_homes(only_active=True):
|
||||
price_coordinator = TibberPriceCoordinator(hass, entry)
|
||||
await price_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.price_coordinator = price_coordinator
|
||||
|
||||
data_coordinator = TibberDataCoordinator(hass, entry, tibber_connection)
|
||||
await data_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.data_coordinator = data_coordinator
|
||||
|
||||
coordinator = TibberDataAPICoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.data_api_coordinator = coordinator
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import random
|
||||
from typing import TYPE_CHECKING, TypedDict, cast
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
@@ -271,9 +272,10 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
name=f"{DOMAIN} price",
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10)
|
||||
|
||||
def _seconds_until_next_15_minute(self) -> float:
|
||||
"""Return seconds until the next 15-minute boundary (0, 15, 30, 45) in UTC."""
|
||||
def _time_until_next_15_minute(self) -> timedelta:
|
||||
"""Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC."""
|
||||
now = dt_util.utcnow()
|
||||
next_minute = ((now.minute // 15) + 1) * 15
|
||||
if next_minute >= 60:
|
||||
@@ -284,7 +286,7 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
next_run = now.replace(
|
||||
minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC
|
||||
)
|
||||
return (next_run - now).total_seconds()
|
||||
return next_run - now
|
||||
|
||||
async def _async_update_data(self) -> dict[str, TibberHomeData]:
|
||||
"""Update data via API and return per-home data for sensors."""
|
||||
@@ -292,22 +294,44 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
self.hass
|
||||
)
|
||||
active_homes = tibber_connection.get_homes(only_active=True)
|
||||
|
||||
now = dt_util.now()
|
||||
today_start = dt_util.start_of_local_day(now)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
tomorrow_start = today_end
|
||||
tomorrow_end = tomorrow_start + timedelta(days=1)
|
||||
|
||||
def _has_prices_today(home: tibber.TibberHome) -> bool:
|
||||
"""Return True if the home has any prices today."""
|
||||
for start in home.price_total:
|
||||
start_dt = dt_util.as_local(datetime.fromisoformat(str(start)))
|
||||
if today_start <= start_dt < today_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_prices_tomorrow(home: tibber.TibberHome) -> bool:
|
||||
"""Return True if the home has any prices tomorrow."""
|
||||
for start in home.price_total:
|
||||
start_dt = dt_util.as_local(datetime.fromisoformat(str(start)))
|
||||
if tomorrow_start <= start_dt < tomorrow_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _needs_update(home: tibber.TibberHome) -> bool:
|
||||
"""Return True if the home needs to be updated."""
|
||||
if not _has_prices_today(home):
|
||||
return True
|
||||
if _has_prices_tomorrow(home):
|
||||
return False
|
||||
if (today_end - now).total_seconds() < (
|
||||
self._tomorrow_price_poll_threshold_seconds
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
homes_to_update = [home for home in active_homes if _needs_update(home)]
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
tibber_connection.fetch_consumption_data_active_homes(),
|
||||
tibber_connection.fetch_production_data_active_homes(),
|
||||
)
|
||||
|
||||
now = dt_util.now()
|
||||
homes_to_update = [
|
||||
home
|
||||
for home in active_homes
|
||||
if (
|
||||
(last_data_timestamp := home.last_data_timestamp) is None
|
||||
or (last_data_timestamp - now).total_seconds() < 11 * 3600
|
||||
)
|
||||
]
|
||||
|
||||
if homes_to_update:
|
||||
await asyncio.gather(
|
||||
*(home.update_info_and_price_info() for home in homes_to_update)
|
||||
@@ -319,7 +343,7 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
|
||||
result = {home.home_id: _build_home_data(home) for home in active_homes}
|
||||
|
||||
self.update_interval = timedelta(seconds=self._seconds_until_next_15_minute())
|
||||
self.update_interval = self._time_until_next_15_minute()
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.36.0"]
|
||||
"requirements": ["pyTibber==0.37.0"]
|
||||
}
|
||||
|
||||
@@ -609,8 +609,8 @@ async def _async_setup_graphql_sensors(
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
coordinator: TibberDataCoordinator | None = None
|
||||
price_coordinator: TibberPriceCoordinator | None = None
|
||||
coordinator = entry.runtime_data.data_coordinator
|
||||
price_coordinator = entry.runtime_data.price_coordinator
|
||||
entities: list[TibberSensor] = []
|
||||
for home in tibber_connection.get_homes(only_active=False):
|
||||
try:
|
||||
@@ -626,12 +626,9 @@ async def _async_setup_graphql_sensors(
|
||||
_LOGGER.error("Error connecting to Tibber home: %s ", err)
|
||||
raise PlatformNotReady from err
|
||||
|
||||
if home.has_active_subscription:
|
||||
if price_coordinator is None:
|
||||
price_coordinator = TibberPriceCoordinator(hass, entry)
|
||||
if price_coordinator is not None and home.has_active_subscription:
|
||||
entities.append(TibberSensorElPrice(price_coordinator, home))
|
||||
if coordinator is None:
|
||||
coordinator = TibberDataCoordinator(hass, entry, tibber_connection)
|
||||
if coordinator is not None and home.has_active_subscription:
|
||||
entities.extend(
|
||||
TibberDataSensor(home, coordinator, entity_description)
|
||||
for entity_description in SENSORS
|
||||
@@ -772,9 +769,15 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
self._model = "Price Sensor"
|
||||
|
||||
self._device_name = self._home_name
|
||||
self._update_attributes()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._update_attributes()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _update_attributes(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
data = self.coordinator.data
|
||||
if not data or (
|
||||
@@ -782,7 +785,6 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
or (current_price := home_data.get("current_price")) is None
|
||||
):
|
||||
self._attr_available = False
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._attr_native_unit_of_measurement = home_data.get(
|
||||
@@ -804,7 +806,6 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
"estimated_annual_consumption"
|
||||
]
|
||||
self._attr_available = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
|
||||
|
||||
@@ -6,6 +6,8 @@ import datetime as dt
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import aiohttp
|
||||
import tibber
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import (
|
||||
@@ -15,7 +17,7 @@ from homeassistant.core import (
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -52,7 +54,52 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
|
||||
|
||||
tibber_prices: dict[str, Any] = {}
|
||||
|
||||
now = dt_util.now()
|
||||
today_start = dt_util.start_of_local_day(now)
|
||||
today_end = today_start + dt.timedelta(days=1)
|
||||
tomorrow_end = today_start + dt.timedelta(days=2)
|
||||
|
||||
def _has_valid_prices(home: tibber.TibberHome) -> bool:
|
||||
"""Return True if the home has valid prices."""
|
||||
for price_start in home.price_total:
|
||||
start_dt = dt_util.as_local(datetime.fromisoformat(str(price_start)))
|
||||
|
||||
if now.hour >= 13:
|
||||
if today_end <= start_dt < tomorrow_end:
|
||||
return True
|
||||
elif today_start <= start_dt < today_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
for tibber_home in tibber_connection.get_homes(only_active=True):
|
||||
if not _has_valid_prices(tibber_home):
|
||||
try:
|
||||
await tibber_home.update_info_and_price_info()
|
||||
except TimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="get_prices_timeout",
|
||||
) from err
|
||||
except tibber.InvalidLoginError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="get_prices_invalid_login",
|
||||
) from err
|
||||
except (
|
||||
tibber.RetryableHttpExceptionError,
|
||||
tibber.FatalHttpExceptionError,
|
||||
) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="get_prices_communication_failed",
|
||||
translation_placeholders={"detail": str(err.status)},
|
||||
) from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="get_prices_communication_failed",
|
||||
translation_placeholders={"detail": str(err)},
|
||||
) from err
|
||||
home_nickname = tibber_home.name
|
||||
|
||||
price_data = [
|
||||
|
||||
@@ -235,6 +235,15 @@
|
||||
"data_api_reauth_required": {
|
||||
"message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features."
|
||||
},
|
||||
"get_prices_communication_failed": {
|
||||
"message": "Could not fetch energy prices from Tibber ({detail})"
|
||||
},
|
||||
"get_prices_invalid_login": {
|
||||
"message": "Could not authenticate with Tibber while fetching prices"
|
||||
},
|
||||
"get_prices_timeout": {
|
||||
"message": "Timeout fetching energy prices from Tibber"
|
||||
},
|
||||
"invalid_date": {
|
||||
"message": "Invalid datetime provided {date}"
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["tplink-omada-client==1.5.6"]
|
||||
"requirements": ["tplink-omada-client==1.5.7"]
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ class TractiveClient:
|
||||
):
|
||||
self._last_hw_time = event["hardware"]["time"]
|
||||
self._send_hardware_update(event)
|
||||
self._send_switch_update(event)
|
||||
if (
|
||||
"position" in event
|
||||
and self._last_pos_time != event["position"]["time"]
|
||||
@@ -302,7 +303,10 @@ class TractiveClient:
|
||||
for switch, key in SWITCH_KEY_MAP.items():
|
||||
if switch_data := event.get(key):
|
||||
payload[switch] = switch_data["active"]
|
||||
payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING"
|
||||
if hardware := event.get("hardware", {}):
|
||||
payload[ATTR_POWER_SAVING] = (
|
||||
hardware.get("power_saving_zone_id") is not None
|
||||
)
|
||||
self._dispatch_tracker_event(
|
||||
TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiotractive"],
|
||||
"requirements": ["aiotractive==1.0.1"]
|
||||
"requirements": ["aiotractive==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -100,13 +100,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity):
|
||||
@callback
|
||||
def handle_status_update(self, event: dict[str, Any]) -> None:
|
||||
"""Handle status update."""
|
||||
if self.entity_description.key not in event:
|
||||
return
|
||||
if ATTR_POWER_SAVING in event:
|
||||
self._attr_available = not event[ATTR_POWER_SAVING]
|
||||
|
||||
# We received an event, so the service is online and the switch entities should
|
||||
# be available.
|
||||
self._attr_available = not event[ATTR_POWER_SAVING]
|
||||
self._attr_is_on = event[self.entity_description.key]
|
||||
if self.entity_description.key in event:
|
||||
self._attr_is_on = event[self.entity_description.key]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"velbus-protocol"
|
||||
],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["velbus-aio==2026.2.0"],
|
||||
"requirements": ["velbus-aio==2026.4.0"],
|
||||
"usb": [
|
||||
{
|
||||
"pid": "0B1B",
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyvlx"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyvlx==0.2.32"]
|
||||
"requirements": ["pyvlx==0.2.33"]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import REAUTH_AFTER_FAILURES
|
||||
from .const import REAUTH_AFTER_FAILURES, VICTRON_IDENTIFIER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,18 +38,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
nonlocal consecutive_failures
|
||||
update = data.update(service_info)
|
||||
|
||||
# If the device type was recognized (devices dict populated) but
|
||||
# only signal strength came back, decryption likely failed.
|
||||
# Unsupported devices have an empty devices dict and won't trigger this.
|
||||
if update.devices and len(update.entity_values) <= 1:
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= REAUTH_AFTER_FAILURES:
|
||||
_LOGGER.debug(
|
||||
"Triggering reauth for %s after %d consecutive failures",
|
||||
address,
|
||||
consecutive_failures,
|
||||
)
|
||||
entry.async_start_reauth(hass)
|
||||
# Only consider a reauth when the device type is recognised (devices
|
||||
# populated) but the advertisement key fails the quick-check built into
|
||||
# validate_advertisement_key. Using the key check instead of counting
|
||||
# entity values avoids false positives: some devices legitimately return
|
||||
# few (or zero) sensor values when in certain error or alarm states.
|
||||
raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER)
|
||||
if update.devices and raw_data is not None:
|
||||
if not data.validate_advertisement_key(raw_data):
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= REAUTH_AFTER_FAILURES:
|
||||
_LOGGER.debug(
|
||||
"Triggering reauth for %s after %d consecutive failures",
|
||||
address,
|
||||
consecutive_failures,
|
||||
)
|
||||
entry.async_start_reauth(hass)
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures = 0
|
||||
|
||||
@@ -54,7 +54,7 @@ class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._discovered_devices_info[discovery_info.address] = discovery_info
|
||||
self._discovered_devices[discovery_info.address] = discovery_info.name
|
||||
|
||||
self.context["title_placeholders"] = {"title": discovery_info.name}
|
||||
self.context["title_placeholders"] = {"name": discovery_info.name}
|
||||
|
||||
return await self.async_step_access_token()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Sensor platform for Victron BLE."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -182,10 +181,6 @@ PARALLEL_UPDATES = 0
|
||||
class VictronBLESensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Victron BLE sensor entity."""
|
||||
|
||||
value_fn: Callable[[float | int | str | None], float | int | str | None] = (
|
||||
lambda x: x
|
||||
)
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = {
|
||||
Keys.AC_IN_POWER: VictronBLESensorEntityDescription(
|
||||
@@ -258,7 +253,6 @@ SENSOR_DESCRIPTIONS = {
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="charger_error",
|
||||
options=CHARGER_ERROR_OPTIONS,
|
||||
value_fn=error_to_state,
|
||||
),
|
||||
Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription(
|
||||
key=Keys.CONSUMED_AMPERE_HOURS,
|
||||
@@ -538,4 +532,6 @@ class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
|
||||
"""Return the state of the sensor."""
|
||||
value = self.processor.entity_data.get(self.entity_key)
|
||||
|
||||
return self.entity_description.value_fn(value)
|
||||
if self.entity_description.key == Keys.CHARGER_ERROR:
|
||||
return error_to_state(value)
|
||||
return value
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"invalid_access_token": "Invalid encryption key for instant readout",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"flow_title": "{title}",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"access_token": {
|
||||
"data": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["holidays"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["holidays==0.93"]
|
||||
"requirements": ["holidays==0.94"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==1.1.1", "serialx==0.6.2"],
|
||||
"requirements": ["zha==1.1.2", "serialx==0.6.2"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["zinvolt"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["zinvolt==0.4.1"]
|
||||
"requirements": ["zinvolt==0.4.3"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 4
|
||||
PATCH_VERSION: Final = "1"
|
||||
PATCH_VERSION: Final = "2"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.requirements import (
|
||||
async_get_integration_with_requirements,
|
||||
)
|
||||
|
||||
from . import config_validation as cv
|
||||
from . import condition, config_validation as cv, trigger
|
||||
from .typing import ConfigType
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@ async def async_check_ha_config_file( # noqa: C901
|
||||
result = HomeAssistantConfig()
|
||||
async_clear_install_history(hass)
|
||||
|
||||
# Set up condition and trigger helpers needed for config validation.
|
||||
if condition.CONDITIONS not in hass.data:
|
||||
await condition.async_setup(hass)
|
||||
if trigger.TRIGGERS not in hass.data:
|
||||
await trigger.async_setup(hass)
|
||||
|
||||
def _pack_error(
|
||||
hass: HomeAssistant,
|
||||
package: str,
|
||||
|
||||
@@ -29,7 +29,7 @@ cached-ipaddress==1.0.1
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.5
|
||||
cryptography==46.0.7
|
||||
dbus-fast==3.1.2
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.0
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.11.1
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260325.6
|
||||
home-assistant-frontend==20260325.7
|
||||
home-assistant-intents==2026.3.24
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
@@ -63,7 +63,7 @@ python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.2.0
|
||||
securetar==2026.4.0
|
||||
SQLAlchemy==2.0.41
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.4.1"
|
||||
version = "2026.4.2"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
@@ -57,7 +57,7 @@ dependencies = [
|
||||
"lru-dict==1.3.0",
|
||||
"PyJWT==2.10.1",
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
"cryptography==46.0.5",
|
||||
"cryptography==46.0.7",
|
||||
"Pillow==12.1.1",
|
||||
"propcache==0.4.1",
|
||||
"pyOpenSSL==26.0.0",
|
||||
@@ -67,7 +67,7 @@ dependencies = [
|
||||
"python-slugify==8.0.4",
|
||||
"PyYAML==6.0.3",
|
||||
"requests==2.33.1",
|
||||
"securetar==2026.2.0",
|
||||
"securetar==2026.4.0",
|
||||
"SQLAlchemy==2.0.41",
|
||||
"standard-aifc==3.13.0",
|
||||
"standard-telnetlib==3.13.0",
|
||||
|
||||
Generated
+2
-2
@@ -21,7 +21,7 @@ bcrypt==5.0.0
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.5
|
||||
cryptography==46.0.7
|
||||
fnv-hash-fast==2.0.0
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==2.2.0
|
||||
@@ -47,7 +47,7 @@ python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.2.0
|
||||
securetar==2026.4.0
|
||||
SQLAlchemy==2.0.41
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
|
||||
Generated
+28
-28
@@ -151,7 +151,7 @@ adguardhome==0.8.1
|
||||
advantage-air==0.4.4
|
||||
|
||||
# homeassistant.components.frontier_silicon
|
||||
afsapi==0.2.7
|
||||
afsapi==0.3.1
|
||||
|
||||
# homeassistant.components.agent_dvr
|
||||
agent-py==0.0.24
|
||||
@@ -224,7 +224,7 @@ aiobafi6==0.9.0
|
||||
aiobotocore==2.21.1
|
||||
|
||||
# homeassistant.components.comelit
|
||||
aiocomelit==2.0.1
|
||||
aiocomelit==2.0.2
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodhcpwatcher==1.2.1
|
||||
@@ -288,7 +288,7 @@ aiohomekit==3.2.20
|
||||
aiohttp_sse==2.2.0
|
||||
|
||||
# homeassistant.components.hue
|
||||
aiohue==4.8.0
|
||||
aiohue==4.8.1
|
||||
|
||||
# homeassistant.components.imap
|
||||
aioimaplib==2.0.1
|
||||
@@ -366,7 +366,7 @@ aiopurpleair==2025.08.1
|
||||
aiopvapi==3.3.0
|
||||
|
||||
# homeassistant.components.pvpc_hourly_pricing
|
||||
aiopvpc==4.2.2
|
||||
aiopvpc==4.3.1
|
||||
|
||||
# homeassistant.components.lidarr
|
||||
# homeassistant.components.radarr
|
||||
@@ -425,7 +425,7 @@ aiotankerkoenig==0.5.1
|
||||
aiotedee==0.3.0
|
||||
|
||||
# homeassistant.components.tractive
|
||||
aiotractive==1.0.1
|
||||
aiotractive==1.0.2
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==88
|
||||
@@ -596,7 +596,7 @@ avea==1.6.1
|
||||
# avion==0.10
|
||||
|
||||
# homeassistant.components.axis
|
||||
axis==67
|
||||
axis==68
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.7
|
||||
@@ -617,7 +617,7 @@ azure-servicebus==7.10.0
|
||||
azure-storage-blob==12.24.0
|
||||
|
||||
# homeassistant.components.backblaze_b2
|
||||
b2sdk==2.10.1
|
||||
b2sdk==2.10.4
|
||||
|
||||
# homeassistant.components.holiday
|
||||
babel==2.15.0
|
||||
@@ -1226,10 +1226,10 @@ hole==0.9.0
|
||||
|
||||
# homeassistant.components.holiday
|
||||
# homeassistant.components.workday
|
||||
holidays==0.93
|
||||
holidays==0.94
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260325.6
|
||||
home-assistant-frontend==20260325.7
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.3.24
|
||||
@@ -1310,7 +1310,7 @@ imeon_inverter_api==0.4.0
|
||||
imgw_pib==2.0.2
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.6.12
|
||||
incomfort-client==0.7.0
|
||||
|
||||
# homeassistant.components.indevolt
|
||||
indevolt-api==1.2.3
|
||||
@@ -1660,7 +1660,7 @@ numpy==2.3.2
|
||||
nyt_games==0.5.0
|
||||
|
||||
# homeassistant.components.oasa_telematics
|
||||
oasatelematics==0.3
|
||||
oasatelematics==0.4
|
||||
|
||||
# homeassistant.components.google
|
||||
oauth2client==4.1.3
|
||||
@@ -1729,7 +1729,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.18.0
|
||||
opower==0.18.1
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.1.0
|
||||
@@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.36.0
|
||||
pyTibber==0.37.0
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -2206,7 +2206,7 @@ pyitachip2ir==0.0.7
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==2.0.3
|
||||
pyjvcprojector==2.0.5
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.1.3
|
||||
@@ -2257,13 +2257,13 @@ pyliebherrhomeapi==0.4.1
|
||||
pylitejet==0.6.3
|
||||
|
||||
# homeassistant.components.litterrobot
|
||||
pylitterbot==2025.2.0
|
||||
pylitterbot==2025.2.1
|
||||
|
||||
# homeassistant.components.lutron_caseta
|
||||
pylutron-caseta==0.27.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.4.0
|
||||
pylutron==0.4.1
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -2317,7 +2317,7 @@ pynina==1.0.2
|
||||
pynintendoauth==1.0.2
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==2.3.3
|
||||
pynintendoparental==2.3.4
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -2436,7 +2436,7 @@ pyrecswitch==1.0.2
|
||||
pyrepetierng==0.1.0
|
||||
|
||||
# homeassistant.components.risco
|
||||
pyrisco==0.6.7
|
||||
pyrisco==0.6.8
|
||||
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.7
|
||||
@@ -2557,7 +2557,7 @@ python-awair==0.2.5
|
||||
python-blockchain-api==0.0.2
|
||||
|
||||
# homeassistant.components.bsblan
|
||||
python-bsblan==5.1.3
|
||||
python-bsblan==5.1.4
|
||||
|
||||
# homeassistant.components.citybikes
|
||||
python-citybikes==0.3.3
|
||||
@@ -2645,7 +2645,7 @@ python-otbr-api==2.9.0
|
||||
python-overseerr==0.9.0
|
||||
|
||||
# homeassistant.components.picnic
|
||||
python-picnic-api2==1.3.1
|
||||
python-picnic-api2==1.3.4
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.9.0
|
||||
@@ -2739,7 +2739,7 @@ pyvesync==3.4.1
|
||||
pyvizio==0.1.61
|
||||
|
||||
# homeassistant.components.velux
|
||||
pyvlx==0.2.32
|
||||
pyvlx==0.2.33
|
||||
|
||||
# homeassistant.components.volumio
|
||||
pyvolumio==0.1.5
|
||||
@@ -2784,7 +2784,7 @@ pyzerproc==0.4.8
|
||||
qbittorrent-api==2024.9.67
|
||||
|
||||
# homeassistant.components.qbus
|
||||
qbusmqttapi==1.4.2
|
||||
qbusmqttapi==1.4.3
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==1.1.0
|
||||
@@ -2892,7 +2892,7 @@ screenlogicpy==0.10.2
|
||||
scsgate==0.1.0
|
||||
|
||||
# homeassistant.components.backup
|
||||
securetar==2026.2.0
|
||||
securetar==2026.4.0
|
||||
|
||||
# homeassistant.components.sendgrid
|
||||
sendgrid==6.8.2
|
||||
@@ -3017,7 +3017,7 @@ starline==0.1.5
|
||||
starlingbank==3.2
|
||||
|
||||
# homeassistant.components.starlink
|
||||
starlink-grpc-core==1.2.4
|
||||
starlink-grpc-core==1.2.5
|
||||
|
||||
# homeassistant.components.statsd
|
||||
statsd==3.2.1
|
||||
@@ -3136,7 +3136,7 @@ toonapi==0.3.0
|
||||
total-connect-client==2025.12.2
|
||||
|
||||
# homeassistant.components.tplink_omada
|
||||
tplink-omada-client==1.5.6
|
||||
tplink-omada-client==1.5.7
|
||||
|
||||
# homeassistant.components.transmission
|
||||
transmission-rpc==7.0.3
|
||||
@@ -3222,7 +3222,7 @@ vegehub==0.1.26
|
||||
vehicle==2.2.2
|
||||
|
||||
# homeassistant.components.velbus
|
||||
velbus-aio==2026.2.0
|
||||
velbus-aio==2026.4.0
|
||||
|
||||
# homeassistant.components.venstar
|
||||
venstarcolortouch==0.21
|
||||
@@ -3383,7 +3383,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==1.1.1
|
||||
zha==1.1.2
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
@@ -3392,7 +3392,7 @@ zhong-hong-hvac==1.0.13
|
||||
ziggo-mediabox-xl==1.1.0
|
||||
|
||||
# homeassistant.components.zinvolt
|
||||
zinvolt==0.4.1
|
||||
zinvolt==0.4.3
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.5.4
|
||||
|
||||
Generated
+27
-27
@@ -142,7 +142,7 @@ adguardhome==0.8.1
|
||||
advantage-air==0.4.4
|
||||
|
||||
# homeassistant.components.frontier_silicon
|
||||
afsapi==0.2.7
|
||||
afsapi==0.3.1
|
||||
|
||||
# homeassistant.components.agent_dvr
|
||||
agent-py==0.0.24
|
||||
@@ -215,7 +215,7 @@ aiobafi6==0.9.0
|
||||
aiobotocore==2.21.1
|
||||
|
||||
# homeassistant.components.comelit
|
||||
aiocomelit==2.0.1
|
||||
aiocomelit==2.0.2
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodhcpwatcher==1.2.1
|
||||
@@ -276,7 +276,7 @@ aiohomekit==3.2.20
|
||||
aiohttp_sse==2.2.0
|
||||
|
||||
# homeassistant.components.hue
|
||||
aiohue==4.8.0
|
||||
aiohue==4.8.1
|
||||
|
||||
# homeassistant.components.imap
|
||||
aioimaplib==2.0.1
|
||||
@@ -351,7 +351,7 @@ aiopurpleair==2025.08.1
|
||||
aiopvapi==3.3.0
|
||||
|
||||
# homeassistant.components.pvpc_hourly_pricing
|
||||
aiopvpc==4.2.2
|
||||
aiopvpc==4.3.1
|
||||
|
||||
# homeassistant.components.lidarr
|
||||
# homeassistant.components.radarr
|
||||
@@ -410,7 +410,7 @@ aiotankerkoenig==0.5.1
|
||||
aiotedee==0.3.0
|
||||
|
||||
# homeassistant.components.tractive
|
||||
aiotractive==1.0.1
|
||||
aiotractive==1.0.2
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==88
|
||||
@@ -548,7 +548,7 @@ autoskope_client==1.4.1
|
||||
av==16.0.1
|
||||
|
||||
# homeassistant.components.axis
|
||||
axis==67
|
||||
axis==68
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.7
|
||||
@@ -566,7 +566,7 @@ azure-kusto-ingest==4.5.1
|
||||
azure-storage-blob==12.24.0
|
||||
|
||||
# homeassistant.components.backblaze_b2
|
||||
b2sdk==2.10.1
|
||||
b2sdk==2.10.4
|
||||
|
||||
# homeassistant.components.holiday
|
||||
babel==2.15.0
|
||||
@@ -1090,10 +1090,10 @@ hole==0.9.0
|
||||
|
||||
# homeassistant.components.holiday
|
||||
# homeassistant.components.workday
|
||||
holidays==0.93
|
||||
holidays==0.94
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260325.6
|
||||
home-assistant-frontend==20260325.7
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.3.24
|
||||
@@ -1162,7 +1162,7 @@ imeon_inverter_api==0.4.0
|
||||
imgw_pib==2.0.2
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.6.12
|
||||
incomfort-client==0.7.0
|
||||
|
||||
# homeassistant.components.indevolt
|
||||
indevolt-api==1.2.3
|
||||
@@ -1509,7 +1509,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.18.0
|
||||
opower==0.18.1
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.1.0
|
||||
@@ -1668,7 +1668,7 @@ pyHomee==1.3.8
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.36.0
|
||||
pyTibber==0.37.0
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1889,7 +1889,7 @@ pyisy==3.4.1
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==2.0.3
|
||||
pyjvcprojector==2.0.5
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.1.3
|
||||
@@ -1934,13 +1934,13 @@ pyliebherrhomeapi==0.4.1
|
||||
pylitejet==0.6.3
|
||||
|
||||
# homeassistant.components.litterrobot
|
||||
pylitterbot==2025.2.0
|
||||
pylitterbot==2025.2.1
|
||||
|
||||
# homeassistant.components.lutron_caseta
|
||||
pylutron-caseta==0.27.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.4.0
|
||||
pylutron==0.4.1
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -1982,7 +1982,7 @@ pynina==1.0.2
|
||||
pynintendoauth==1.0.2
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==2.3.3
|
||||
pynintendoparental==2.3.4
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -2083,7 +2083,7 @@ pyrainbird==6.1.1
|
||||
pyrate-limiter==4.1.0
|
||||
|
||||
# homeassistant.components.risco
|
||||
pyrisco==0.6.7
|
||||
pyrisco==0.6.8
|
||||
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.7
|
||||
@@ -2186,7 +2186,7 @@ python-MotionMount==2.3.0
|
||||
python-awair==0.2.5
|
||||
|
||||
# homeassistant.components.bsblan
|
||||
python-bsblan==5.1.3
|
||||
python-bsblan==5.1.4
|
||||
|
||||
# homeassistant.components.ecobee
|
||||
python-ecobee-api==0.3.2
|
||||
@@ -2247,7 +2247,7 @@ python-otbr-api==2.9.0
|
||||
python-overseerr==0.9.0
|
||||
|
||||
# homeassistant.components.picnic
|
||||
python-picnic-api2==1.3.1
|
||||
python-picnic-api2==1.3.4
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.9.0
|
||||
@@ -2329,7 +2329,7 @@ pyvesync==3.4.1
|
||||
pyvizio==0.1.61
|
||||
|
||||
# homeassistant.components.velux
|
||||
pyvlx==0.2.32
|
||||
pyvlx==0.2.33
|
||||
|
||||
# homeassistant.components.volumio
|
||||
pyvolumio==0.1.5
|
||||
@@ -2368,7 +2368,7 @@ pyzerproc==0.4.8
|
||||
qbittorrent-api==2024.9.67
|
||||
|
||||
# homeassistant.components.qbus
|
||||
qbusmqttapi==1.4.2
|
||||
qbusmqttapi==1.4.3
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==1.1.0
|
||||
@@ -2452,7 +2452,7 @@ satel-integra==1.0.0
|
||||
screenlogicpy==0.10.2
|
||||
|
||||
# homeassistant.components.backup
|
||||
securetar==2026.2.0
|
||||
securetar==2026.4.0
|
||||
|
||||
# homeassistant.components.emulated_kasa
|
||||
# homeassistant.components.sense
|
||||
@@ -2556,7 +2556,7 @@ srpenergy==1.3.8
|
||||
starline==0.1.5
|
||||
|
||||
# homeassistant.components.starlink
|
||||
starlink-grpc-core==1.2.4
|
||||
starlink-grpc-core==1.2.5
|
||||
|
||||
# homeassistant.components.statsd
|
||||
statsd==3.2.1
|
||||
@@ -2648,7 +2648,7 @@ toonapi==0.3.0
|
||||
total-connect-client==2025.12.2
|
||||
|
||||
# homeassistant.components.tplink_omada
|
||||
tplink-omada-client==1.5.6
|
||||
tplink-omada-client==1.5.7
|
||||
|
||||
# homeassistant.components.transmission
|
||||
transmission-rpc==7.0.3
|
||||
@@ -2728,7 +2728,7 @@ vegehub==0.1.26
|
||||
vehicle==2.2.2
|
||||
|
||||
# homeassistant.components.velbus
|
||||
velbus-aio==2026.2.0
|
||||
velbus-aio==2026.4.0
|
||||
|
||||
# homeassistant.components.venstar
|
||||
venstarcolortouch==0.21
|
||||
@@ -2865,10 +2865,10 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==1.1.1
|
||||
zha==1.1.2
|
||||
|
||||
# homeassistant.components.zinvolt
|
||||
zinvolt==0.4.1
|
||||
zinvolt==0.4.3
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.5.4
|
||||
|
||||
@@ -4,9 +4,11 @@ from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
|
||||
from homeassistant.components.alexa_devices.const import DOMAIN
|
||||
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import setup_integration
|
||||
from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN
|
||||
@@ -50,3 +52,33 @@ async def test_coordinator_stale_device(
|
||||
|
||||
# Entity is removed
|
||||
assert not hass.states.get(entity_id_1)
|
||||
|
||||
|
||||
async def test_coordinator_load_previous_devices_from_registry(
|
||||
hass: HomeAssistant,
|
||||
mock_amazon_devices_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test coordinator preloads previous devices from registry excluding services."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
|
||||
name="Echo Test",
|
||||
manufacturer="Amazon",
|
||||
model="Echo Dot",
|
||||
)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)},
|
||||
name=mock_config_entry.title,
|
||||
manufacturer="Amazon",
|
||||
model="Echo Dot",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
coordinator = mock_config_entry.runtime_data
|
||||
assert coordinator.previous_devices == {TEST_DEVICE_1_SN}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for the Anglian Water coordinator."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyanglianwater.meter import SmartMeter
|
||||
import pytest
|
||||
@@ -162,3 +163,92 @@ async def test_coordinator_invalid_readings(
|
||||
"Could not parse read_at time also-invalid-date, skipping reading"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
async def test_coordinator_subsequent_run_missing_period_statistics(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_smart_meter: SmartMeter,
|
||||
mock_anglian_water_client: AsyncMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test the coordinator handles missing period lookup statistics."""
|
||||
coordinator = AnglianWaterUpdateCoordinator(
|
||||
hass, mock_anglian_water_client, mock_config_entry
|
||||
)
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Correct the latest already-stored reading. Fallback should still update
|
||||
# this hour instead of skipping it.
|
||||
mock_smart_meter.readings[-1] = {
|
||||
"read_at": "2024-06-01T14:00:00",
|
||||
"consumption": 35,
|
||||
"read": 70,
|
||||
}
|
||||
|
||||
# Add a new later reading to ensure fallback also accepts newer entries.
|
||||
mock_smart_meter.readings.append(
|
||||
{"read_at": "2024-06-01T15:00:00", "consumption": 20, "read": 90}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.anglian_water.coordinator.statistics_during_period",
|
||||
return_value={},
|
||||
):
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
assert "Could not find existing statistics during period lookup" in caplog.text
|
||||
|
||||
statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage"
|
||||
stats = await hass.async_add_executor_job(
|
||||
get_last_statistics, hass, 1, statistic_id, True, {"sum"}
|
||||
)
|
||||
assert stats[statistic_id][0]["sum"] >= 70
|
||||
|
||||
parsed_read_at = dt_util.parse_datetime("2024-06-01T14:00:00")
|
||||
assert parsed_read_at is not None
|
||||
corrected_start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
|
||||
corrected_stats = await hass.async_add_executor_job(
|
||||
statistics_during_period,
|
||||
hass,
|
||||
corrected_start,
|
||||
corrected_start + timedelta(seconds=1),
|
||||
{
|
||||
statistic_id,
|
||||
},
|
||||
"hour",
|
||||
None,
|
||||
{"sum"},
|
||||
)
|
||||
assert corrected_stats[statistic_id][0]["sum"] == 70
|
||||
|
||||
|
||||
async def test_coordinator_period_statistics_without_sum(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_anglian_water_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test period lookup records without sum are handled safely."""
|
||||
coordinator = AnglianWaterUpdateCoordinator(
|
||||
hass, mock_anglian_water_client, mock_config_entry
|
||||
)
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage"
|
||||
with patch(
|
||||
"homeassistant.components.anglian_water.coordinator.statistics_during_period",
|
||||
return_value={statistic_id: [{"start": 0.0}]},
|
||||
):
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
stats = await hass.async_add_executor_job(
|
||||
get_last_statistics, hass, 1, statistic_id, True, {"sum"}
|
||||
)
|
||||
assert stats[statistic_id]
|
||||
|
||||
@@ -68,8 +68,8 @@ class RtspEventMock(Protocol):
|
||||
|
||||
|
||||
class _RtspClientMock(Protocol):
|
||||
async def __call__(
|
||||
self, data: dict[str, Any] | None = None, state: str = ""
|
||||
def __call__(
|
||||
self, data: bytes | None = None, state: Signal | None = None
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@@ -337,14 +337,16 @@ def fixture_axis_rtsp_client() -> Generator[_RtspClientMock]:
|
||||
|
||||
rtsp_client_mock.return_value.stop = stop_stream
|
||||
|
||||
def make_rtsp_call(data: dict[str, Any] | None = None, state: str = "") -> None:
|
||||
def make_rtsp_call(
|
||||
data: bytes | None = None, state: Signal | None = None
|
||||
) -> None:
|
||||
"""Generate a RTSP call."""
|
||||
axis_streammanager_session_callback = rtsp_client_mock.call_args[0][4]
|
||||
|
||||
if data:
|
||||
rtsp_client_mock.return_value.rtp.data = data
|
||||
if data is not None:
|
||||
rtsp_client_mock.return_value.data = data
|
||||
axis_streammanager_session_callback(signal=Signal.DATA)
|
||||
elif state:
|
||||
elif state is not None:
|
||||
axis_streammanager_session_callback(signal=state)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
@@ -73,6 +74,33 @@ async def test_device_support_mqtt(
|
||||
assert pir.name == f"{NAME} PIR 0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT])
|
||||
@pytest.mark.usefixtures("config_entry_setup")
|
||||
async def test_device_support_mqtt_without_required_event_keys(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock: MqttMockHAClient,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Ignore non-event MQTT payloads without raising callback exceptions."""
|
||||
caplog.set_level(logging.ERROR, logger="homeassistant.components.mqtt.client")
|
||||
|
||||
mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8", ANY)
|
||||
assert mqtt_call in mqtt_mock.async_subscribe.call_args_list
|
||||
|
||||
topic = f"axis/{MAC}/device"
|
||||
message = (
|
||||
b'{"timestamp": 1775115420, "time": "2026-04-02T09:37:00+0200", '
|
||||
b'"zone": "CEST", "ip": "1.2.3.4", "host": "hostname", '
|
||||
b'"temperature": {"temp_main": 23.5, "temp_cpu": 24.0}, '
|
||||
b'"power": {"pwr": 4.76, "pwr-avg": 3.88, "pwr-max": 9.13}}'
|
||||
)
|
||||
|
||||
async_fire_mqtt_message(hass, topic, message)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "Exception in _mqtt_message" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT])
|
||||
@pytest.mark.parametrize("mqtt_status_code", [401])
|
||||
@pytest.mark.usefixtures("config_entry_setup")
|
||||
|
||||
@@ -510,7 +510,93 @@ async def test_upload_with_cleanup_failure(
|
||||
|
||||
assert resp.status == 201
|
||||
assert any(
|
||||
"Failed to clean up partially uploaded main backup file" in msg
|
||||
"Failed to clean up partially uploaded backup file" in msg
|
||||
for msg in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
async def test_tar_upload_failure_skips_cleanup(
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that cleanup is not attempted when tar upload itself fails."""
|
||||
client = await hass_client()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
||||
return_value=TEST_BACKUP,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=TEST_BACKUP,
|
||||
),
|
||||
patch("pathlib.Path.open") as mocked_open,
|
||||
patch.object(
|
||||
BucketSimulator,
|
||||
"upload_unbound_stream",
|
||||
side_effect=B2Error("Connection reset"),
|
||||
),
|
||||
patch.object(
|
||||
BucketSimulator,
|
||||
"get_file_info_by_name",
|
||||
) as mock_get_file_info,
|
||||
caplog.at_level(logging.DEBUG),
|
||||
):
|
||||
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
|
||||
resp = await client.post(
|
||||
f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
|
||||
data={"file": StringIO("test")},
|
||||
)
|
||||
|
||||
assert resp.status == 201
|
||||
mock_get_file_info.assert_not_called()
|
||||
assert not any(
|
||||
"Attempting to delete partially uploaded" in msg for msg in caplog.messages
|
||||
)
|
||||
assert any("Connection reset" in msg for msg in caplog.messages)
|
||||
|
||||
|
||||
async def test_handle_b2_errors_logs_root_cause(
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that the actual B2 error is logged when upload fails."""
|
||||
client = await hass_client()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
||||
return_value=TEST_BACKUP,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=TEST_BACKUP,
|
||||
),
|
||||
patch("pathlib.Path.open") as mocked_open,
|
||||
patch.object(
|
||||
BucketSimulator,
|
||||
"upload_bytes",
|
||||
side_effect=B2Error("Service unavailable"),
|
||||
),
|
||||
patch.object(
|
||||
BucketSimulator,
|
||||
"get_file_info_by_name",
|
||||
return_value=Mock(delete=Mock()),
|
||||
),
|
||||
caplog.at_level(logging.ERROR),
|
||||
):
|
||||
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
|
||||
resp = await client.post(
|
||||
f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
|
||||
data={"file": StringIO("test")},
|
||||
)
|
||||
|
||||
assert resp.status == 201
|
||||
assert any(
|
||||
"Failed during async_upload_backup: Service unavailable" in msg
|
||||
for msg in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
@@ -177,6 +177,16 @@ async def test_already_configured(
|
||||
"cannot_connect",
|
||||
"base",
|
||||
),
|
||||
(
|
||||
"bad_request",
|
||||
{
|
||||
"patch": "b2sdk.v2.RawSimulator.authorize_account",
|
||||
"exception": exception.BadRequest,
|
||||
"args": ["test", "bad_request"],
|
||||
},
|
||||
"bad_request",
|
||||
"base",
|
||||
),
|
||||
(
|
||||
"unknown_error",
|
||||
{
|
||||
@@ -252,6 +262,11 @@ async def test_config_flow_errors(
|
||||
"brand_name": "Backblaze B2",
|
||||
"allowed_prefix": "test/",
|
||||
}
|
||||
elif error_type == "bad_request":
|
||||
assert result.get("description_placeholders") == {
|
||||
"brand_name": "Backblaze B2",
|
||||
"error_message": "test (bad_request)",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -57,6 +57,7 @@ async def test_setup_entry_invalid_auth(
|
||||
(exception.RestrictedBucket("testBucket"), ConfigEntryState.SETUP_RETRY),
|
||||
(exception.NonExistentBucket(), ConfigEntryState.SETUP_RETRY),
|
||||
(exception.ConnectionReset(), ConfigEntryState.SETUP_RETRY),
|
||||
(exception.BadRequest("test", "bad_request"), ConfigEntryState.SETUP_RETRY),
|
||||
(exception.MissingAccountData("key"), ConfigEntryState.SETUP_ERROR),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -282,14 +282,14 @@ def test_validate_password_no_homeassistant(caplog: pytest.LogCaptureFixture) ->
|
||||
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
|
||||
AddonInfo(name="Core 2", slug="core2", version="1.0.0"),
|
||||
],
|
||||
40960, # 4 x 10240 byte of padding
|
||||
51200, # 5 x 10240 byte of padding
|
||||
"test_backups/c0cb53bd.tar.decrypted",
|
||||
),
|
||||
(
|
||||
[
|
||||
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
|
||||
],
|
||||
30720, # 3 x 10240 byte of padding
|
||||
40960, # 4 x 10240 byte of padding
|
||||
"test_backups/c0cb53bd.tar.decrypted_skip_core2",
|
||||
),
|
||||
],
|
||||
@@ -460,14 +460,14 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) ->
|
||||
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
|
||||
AddonInfo(name="Core 2", slug="core2", version="1.0.0"),
|
||||
],
|
||||
40960, # 4 x 10240 byte of padding
|
||||
51200, # 5 x 10240 byte of padding
|
||||
"test_backups/c0cb53bd.tar.encrypted_v3",
|
||||
),
|
||||
(
|
||||
[
|
||||
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
|
||||
],
|
||||
30720, # 3 x 10240 byte of padding
|
||||
40960, # 4 x 10240 byte of padding
|
||||
"test_backups/c0cb53bd.tar.encrypted_v3_skip_core2",
|
||||
),
|
||||
],
|
||||
@@ -674,8 +674,8 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No
|
||||
# Expect the output length to match the stored encrypted backup file, with
|
||||
# additional padding.
|
||||
encrypted_backup_data = encrypted_backup_path.read_bytes()
|
||||
# 4 x 10240 byte of padding
|
||||
assert len(encrypted_output1) == len(encrypted_backup_data) + 40960
|
||||
# 5 x 10240 byte of padding
|
||||
assert len(encrypted_output1) == len(encrypted_backup_data) + 51200
|
||||
assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
@@ -201,6 +204,30 @@ async def test_config_entry_timeout_error(
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_coordinator_fast_no_dhw_support(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_bsblan: MagicMock,
|
||||
) -> None:
|
||||
"""Test fast coordinator when device does not support DHW."""
|
||||
mock_bsblan.hot_water_state.side_effect = BSBLANError(
|
||||
"None of the requested parameters are valid for this section"
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Integration should still load even if DHW is not supported
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
# DHW data should be None in the fast coordinator
|
||||
assert mock_config_entry.runtime_data.fast_coordinator.data.dhw is None
|
||||
|
||||
# Water heater entity should not be created
|
||||
assert hass.states.get("water_heater.bsb_lan") is None
|
||||
|
||||
|
||||
async def test_coordinator_slow_no_dhw_support(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -221,3 +248,50 @@ async def test_coordinator_slow_no_dhw_support(
|
||||
|
||||
# Verify slow coordinator handled the AttributeError gracefully
|
||||
assert mock_bsblan.hot_water_config.called
|
||||
|
||||
|
||||
async def test_configuration_url_default_port(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_bsblan: MagicMock,
|
||||
) -> None:
|
||||
"""Test configuration_url omits port 80 (HTTP default)."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, "00:80:41:19:69:90")}
|
||||
)
|
||||
assert device is not None
|
||||
assert device.configuration_url == "http://127.0.0.1"
|
||||
|
||||
|
||||
async def test_configuration_url_non_default_port(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_bsblan: MagicMock,
|
||||
) -> None:
|
||||
"""Test configuration_url includes port when it differs from the default."""
|
||||
config_entry = MockConfigEntry(
|
||||
title="BSBLAN Setup",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_PORT: 8080,
|
||||
CONF_PASSKEY: "1234",
|
||||
CONF_USERNAME: "admin",
|
||||
CONF_PASSWORD: "admin1234",
|
||||
},
|
||||
unique_id="00:80:41:19:69:90",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, "00:80:41:19:69:90")}
|
||||
)
|
||||
assert device is not None
|
||||
assert device.configuration_url == "http://192.168.1.100:8080"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user