Compare commits

..

7 Commits

Author SHA1 Message Date
Stefan Agner fb68146bce Fix diagnostics tests 2025-06-24 17:23:54 +02:00
Stefan Agner 072c570660 More pytest fixes 2025-06-24 17:23:54 +02:00
Stefan Agner be62023040 Update pytests 2025-06-24 17:23:54 +02:00
Stefan Agner b9c563538a Update pytests 2025-06-24 17:23:54 +02:00
Stefan Agner 0e78002c4a Set default update interval 2025-06-24 17:23:54 +02:00
Stefan Agner e921373833 Use /addons to get list of add-ons 2025-06-24 17:23:54 +02:00
Stefan Agner ccc7eec253 Split hassio data coordinator
Use two data coordinators for hassio data, one for the Core,
Supervisor, and Operating System updates, and one for the add-on
updates. This allows the add-on updates to be fetched independently
of the Core, Supervisor, and Operating System updates.
2025-06-24 17:23:51 +02:00
267 changed files with 3737 additions and 10611 deletions
Generated
+2 -2
View File
@@ -1169,8 +1169,8 @@ build.json @home-assistant/supervisor
/tests/components/ping/ @jpbede
/homeassistant/components/plaato/ @JohNan
/tests/components/plaato/ @JohNan
/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
/tests/components/playstation_network/ @jackjpowell @tr4nt0r
/homeassistant/components/playstation_network/ @jackjpowell
/tests/components/playstation_network/ @jackjpowell
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
-2
View File
@@ -89,7 +89,6 @@ from .helpers import (
restore_state,
template,
translation,
trigger,
)
from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
@@ -453,7 +452,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(trigger.async_setup(hass)),
)
-3
View File
@@ -185,7 +185,6 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
name="Daily forecast wind bearing",
native_unit_of_measurement=DEGREE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
entity_registry_enabled_default=False,
@@ -193,7 +192,6 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
name="Hourly forecast wind bearing",
native_unit_of_measurement=DEGREE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
entity_registry_enabled_default=False,
@@ -337,7 +335,6 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
name="Wind bearing",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
key=ATTR_API_WIND_MAX_SPEED,
@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
data: dict[str, Any] = {}
data = {}
try:
obs = await self.airnow.observations.latLong(
self.latitude,
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airnow",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
"requirements": ["pyairnow==1.3.1"]
"requirements": ["pyairnow==1.2.1"]
}
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from typing import Final
from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -29,8 +28,7 @@ PARALLEL_UPDATES = 0
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Alexa Devices binary sensor entity description."""
is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
is_on_fn: Callable[[AmazonDevice], bool]
BINARY_SENSORS: Final = (
@@ -38,49 +36,13 @@ BINARY_SENSORS: Final = (
key="online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda device, _: device.online,
is_on_fn=lambda _device: _device.online,
),
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda device, _: device.bluetooth_state,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="humanPresenceDetectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
is_on_fn=lambda _device: _device.bluetooth_state,
),
)
@@ -98,7 +60,6 @@ async def async_setup_entry(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
)
@@ -110,6 +71,4 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self.entity_description.is_on_fn(
self.device, self.entity_description.key
)
return self.entity_description.is_on_fn(self.device)
@@ -2,39 +2,9 @@
"entity": {
"binary_sensor": {
"bluetooth": {
"default": "mdi:bluetooth-off",
"default": "mdi:bluetooth",
"state": {
"on": "mdi:bluetooth"
}
},
"baby_cry_detection": {
"default": "mdi:account-voice-off",
"state": {
"on": "mdi:account-voice"
}
},
"beeping_appliance_detection": {
"default": "mdi:bell-off",
"state": {
"on": "mdi:bell-ring"
}
},
"cough_detection": {
"default": "mdi:blur-off",
"state": {
"on": "mdi:blur"
}
},
"dog_bark_detection": {
"default": "mdi:dog-side-off",
"state": {
"on": "mdi:dog-side"
}
},
"water_sounds_detection": {
"default": "mdi:water-pump-off",
"state": {
"on": "mdi:water-pump"
"off": "mdi:bluetooth-off"
}
}
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.19"]
"requirements": ["aioamazondevices==3.1.14"]
}
@@ -41,21 +41,6 @@
"binary_sensor": {
"bluetooth": {
"name": "Bluetooth"
},
"baby_cry_detection": {
"name": "Baby crying"
},
"beeping_appliance_detection": {
"name": "Beeping appliance"
},
"cough_detection": {
"name": "Coughing"
},
"dog_bark_detection": {
"name": "Dog barking"
},
"water_sounds_detection": {
"name": "Water sounds"
}
},
"notify": {
+3 -20
View File
@@ -260,18 +260,11 @@ class APIEntityStateView(HomeAssistantView):
if not user.is_admin:
raise Unauthorized(entity_id=entity_id)
hass = request.app[KEY_HASS]
body = await request.text()
try:
data: Any = json_loads(body) if body else None
data = await request.json()
except ValueError:
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
if not isinstance(data, dict):
return self.json_message(
"State data should be a JSON object.", HTTPStatus.BAD_REQUEST
)
if (new_state := data.get("state")) is None:
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
@@ -484,19 +477,9 @@ class APITemplateView(HomeAssistantView):
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Render a template."""
body = await request.text()
try:
data: Any = json_loads(body) if body else None
except ValueError:
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
if not isinstance(data, dict):
return self.json_message(
"Template data should be a JSON object.", HTTPStatus.BAD_REQUEST
)
tpl = _cached_template(data["template"], request.app[KEY_HASS])
try:
data = await request.json()
tpl = _cached_template(data["template"], request.app[KEY_HASS])
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
except (ValueError, TemplateError) as ex:
return self.json_message(
@@ -1119,7 +1119,6 @@ class PipelineRun:
) is not None:
# Sentence trigger matched
agent_id = "sentence_trigger"
processed_locally = True
intent_response = intent.IntentResponse(
self.pipeline.conversation_language
)
@@ -86,17 +86,3 @@ ask_question:
required: false
selector:
object:
label_field: sentences
description_field: id
multiple: true
translation_key: answers
fields:
id:
required: true
selector:
text:
sentences:
required: true
selector:
text:
multiple: true
@@ -90,13 +90,5 @@
}
}
}
},
"selector": {
"answers": {
"fields": {
"id": "Answer ID",
"sentences": "Sentences"
}
}
}
}
+1 -1
View File
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.104.0"],
"requirements": ["hass-nabucasa==0.103.0"],
"single_config_entry": true
}
+21 -4
View File
@@ -336,7 +336,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
"" if unit is None else unit
)
self._prune_state_list(new_state.last_reported)
# filter out all derivatives older than `time_window` from our window list
self._state_list = [
(time_start, time_end, state)
for time_start, time_end, state in self._state_list
if (new_state.last_reported - time_end).total_seconds()
< self._time_window
]
try:
elapsed_time = (
@@ -374,14 +380,25 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
(old_last_reported, new_state.last_reported, new_derivative)
)
def calculate_weight(
start: datetime, end: datetime, now: datetime
) -> float:
window_start = now - timedelta(seconds=self._time_window)
if start < window_start:
weight = (end - window_start).total_seconds() / self._time_window
else:
weight = (end - start).total_seconds() / self._time_window
return weight
# If outside of time window just report derivative (is the same as modeling it in the window),
# otherwise take the weighted average with the previous derivatives
if elapsed_time > self._time_window:
derivative = new_derivative
else:
derivative = self._calc_derivative_from_state_list(
new_state.last_reported
)
derivative = Decimal("0.00")
for start, end, value in self._state_list:
weight = calculate_weight(start, end, new_state.last_reported)
derivative = derivative + (value * Decimal(weight))
self._attr_native_value = round(derivative, self._round_digits)
self.async_write_ha_state()
@@ -10,7 +10,6 @@ from homeassistant.const import CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
trace_condition_function,
)
@@ -52,38 +51,20 @@ class DeviceAutomationConditionProtocol(Protocol):
"""List conditions."""
class DeviceCondition(Condition):
"""Device condition."""
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Initialize condition."""
self._config = config
self._hass = hass
@classmethod
async def async_validate_condition_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate device condition config."""
return await async_validate_device_automation_config(
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
)
async def async_condition_from_config(self) -> condition.ConditionCheckerType:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
return trace_condition_function(
platform.async_condition_from_config(self._hass, self._config)
)
async def async_validate_condition_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate device condition config."""
return await async_validate_device_automation_config(
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
)
CONDITIONS: dict[str, type[Condition]] = {
"device": DeviceCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the sun conditions."""
return CONDITIONS
async def async_condition_from_config(
hass: HomeAssistant, config: ConfigType
) -> condition.ConditionCheckerType:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
return trace_condition_function(platform.async_condition_from_config(hass, config))
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloMultiLevelSwitchDeviceEntity
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloMultiLevelSwitchDeviceEntity
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
@@ -0,0 +1,27 @@
"""Base class for multi level switches in devolo Home Control."""
from devolo_home_control_api.devices.zwave import Zwave
from devolo_home_control_api.homecontrol import HomeControl
from .entity import DevoloDeviceEntity
class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity):
"""Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat."""
_attr_name = None
def __init__(
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
) -> None:
"""Initialize a multi level switch within devolo Home Control."""
super().__init__(
homecontrol=homecontrol,
device_instance=device_instance,
element_uid=element_uid,
)
self._multi_level_switch_property = device_instance.multi_level_switch_property[
element_uid
]
self._value = self._multi_level_switch_property.value
@@ -90,24 +90,3 @@ class DevoloDeviceEntity(Entity):
self._attr_available = self._device_instance.is_online()
else:
_LOGGER.debug("No valid message received: %s", message)
class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity):
"""Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat."""
_attr_name = None
def __init__(
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
) -> None:
"""Initialize a multi level switch within devolo Home Control."""
super().__init__(
homecontrol=homecontrol,
device_instance=device_instance,
element_uid=element_uid,
)
self._multi_level_switch_property = device_instance.multi_level_switch_property[
element_uid
]
self._value = self._multi_level_switch_property.value
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloMultiLevelSwitchDeviceEntity
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloMultiLevelSwitchDeviceEntity
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
@@ -19,16 +19,6 @@
"password": "Password of your mydevolo account."
}
},
"reauth_confirm": {
"data": {
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]",
"password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]"
}
},
"zeroconf_confirm": {
"data": {
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["py-dormakaba-dkey==1.0.6"]
"requirements": ["py-dormakaba-dkey==1.0.5"]
}
@@ -284,15 +284,11 @@ class EsphomeAssistSatellite(
assert event.data is not None
data_to_send = {"text": event.data["stt_output"]["text"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
if (
not event.data
or ("tts_start_streaming" not in event.data)
or (not event.data["tts_start_streaming"])
):
# ESPHome only needs to know if early TTS streaming is available
return
data_to_send = {"tts_start_streaming": "1"}
data_to_send = {
"tts_start_streaming": "1"
if (event.data and event.data.get("tts_start_streaming"))
else "0",
}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
assert event.data is not None
data_to_send = {
+15 -90
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
import functools
import logging
import math
from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast
@@ -14,6 +13,7 @@ from aioesphomeapi import (
EntityCategory as EsphomeEntityCategory,
EntityInfo,
EntityState,
build_unique_id,
)
import voluptuous as vol
@@ -24,7 +24,6 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -33,11 +32,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
_LOGGER = logging.getLogger(__name__)
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
_StateT = TypeVar("_StateT", bound=EntityState)
@@ -56,74 +53,21 @@ def async_static_info_updated(
) -> None:
"""Update entities of this platform when entities are listed."""
current_infos = entry_data.info[info_type]
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
new_infos: dict[int, EntityInfo] = {}
add_entities: list[_EntityT] = []
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
for info in infos:
if not current_infos.pop(info.key, None):
# Create new entity
entity = entity_type(entry_data, platform.domain, info, state_type)
add_entities.append(entity)
new_infos[info.key] = info
# Create new entity if it doesn't exist
if not (old_info := current_infos.pop(info.key, None)):
entity = entity_type(entry_data, platform.domain, info, state_type)
add_entities.append(entity)
continue
# Entity exists - check if device_id has changed
if old_info.device_id == info.device_id:
continue
# Entity has switched devices, need to migrate unique_id
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
# If entity not found in registry, re-add it
# This happens when the device_id changed and the old device was deleted
if entity_id is None:
_LOGGER.info(
"Entity with old unique_id %s not found in registry after device_id "
"changed from %s to %s, re-adding entity",
old_unique_id,
old_info.device_id,
info.device_id,
)
entity = entity_type(entry_data, platform.domain, info, state_type)
add_entities.append(entity)
continue
updates: dict[str, Any] = {}
new_unique_id = build_device_unique_id(device_info.mac_address, info)
# Update unique_id if it changed
if old_unique_id != new_unique_id:
updates["new_unique_id"] = new_unique_id
# Update device assignment
if info.device_id:
# Entity now belongs to a sub device
new_device = dev_reg.async_get_device(
identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")}
)
else:
# Entity now belongs to the main device
new_device = dev_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
if new_device:
updates["device_id"] = new_device.id
# Apply all updates at once
if updates:
ent_reg.async_update_entity(entity_id, **updates)
# Anything still in current_infos is now gone
if current_infos:
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
entry_data.async_remove_entities(
hass, current_infos.values(), device_info.mac_address
)
@@ -300,28 +244,11 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
self._key = entity_info.key
self._state_type = state_type
self._on_static_info_update(entity_info)
device_name = device_info.name
# Determine the device connection based on whether this entity belongs to a sub device
if entity_info.device_id:
# Entity belongs to a sub device
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}")
}
)
# Use the pre-computed device_id_to_name mapping for O(1) lookup
device_name = entry_data.device_id_to_name.get(
entity_info.device_id, device_info.name
)
else:
# Entity belongs to the main device
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
if entity_info.name:
self.entity_id = f"{domain}.{device_name}_{entity_info.name}"
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
else:
# https://github.com/home-assistant/core/issues/132532
# If name is not set, ESPHome will use the sanitized friendly name
@@ -329,7 +256,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
# as the entity_id before it is sanitized since the sanitizer
# is not utf-8 aware. In this case, its always going to be
# an empty string so we drop the object_id.
self.entity_id = f"{domain}.{device_name}"
self.entity_id = f"{domain}.{device_info.name}"
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -363,9 +290,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
static_info = cast(_InfoT, static_info)
assert device_info
self._static_info = static_info
self._attr_unique_id = build_device_unique_id(
device_info.mac_address, static_info
)
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
# https://github.com/home-assistant/core/issues/132532
# If the name is "", we need to set it to None since otherwise
+2 -22
View File
@@ -95,22 +95,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
}
def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str:
"""Build unique ID for entity, appending @device_id if it belongs to a sub-device.
This wrapper around build_unique_id ensures that entities belonging to sub-devices
have their device_id appended to the unique_id to handle proper migration when
entities move between devices.
"""
base_unique_id = build_unique_id(mac, entity_info)
# If entity belongs to a sub-device, append @device_id
if entity_info.device_id:
return f"{base_unique_id}@{entity_info.device_id}"
return base_unique_id
class StoreData(TypedDict, total=False):
"""ESPHome storage data."""
@@ -176,7 +160,6 @@ class RuntimeEntryData:
assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
default_factory=list
)
device_id_to_name: dict[int, str] = field(default_factory=dict)
@property
def name(self) -> str:
@@ -239,9 +222,7 @@ class RuntimeEntryData:
ent_reg = er.async_get(hass)
for info in static_infos:
if entry := ent_reg.async_get_entity_id(
INFO_TYPE_TO_PLATFORM[type(info)],
DOMAIN,
build_device_unique_id(mac, info),
INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info)
):
ent_reg.async_remove(entry)
@@ -297,8 +278,7 @@ class RuntimeEntryData:
if (
(old_unique_id := info.unique_id)
and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id))
and (new_unique_id := build_device_unique_id(mac, info))
!= old_unique_id
and (new_unique_id := build_unique_id(mac, info)) != old_unique_id
and not registry_get_entity(platform, DOMAIN, new_unique_id)
):
ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)
+3 -63
View File
@@ -527,11 +527,6 @@ class ESPHomeManager:
device_info.name,
device_mac,
)
# Build device_id_to_name mapping for efficient lookup
entry_data.device_id_to_name = {
sub_device.device_id: sub_device.name or device_info.name
for sub_device in device_info.devices
}
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
entry_data.async_update_device_state()
@@ -756,28 +751,6 @@ def _async_setup_device_registry(
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
device_registry = dr.async_get(hass)
# Build sets of valid device identifiers and connections
valid_connections = {
(dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address))
}
valid_identifiers = {
(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")
for sub_device in device_info.devices
}
# Remove devices that no longer exist
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
# Skip devices we want to keep
if (
device.connections & valid_connections
or device.identifiers & valid_identifiers
):
continue
# Remove everything else
device_registry.async_remove_device(device.id)
sw_version = device_info.esphome_version
if device_info.compilation_time:
sw_version += f" ({device_info.compilation_time})"
@@ -806,14 +779,11 @@ def _async_setup_device_registry(
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
)
suggested_area: str | None = None
if device_info.area and device_info.area.name:
# Prefer device_info.area over suggested_area when area name is not empty
suggested_area = device_info.area.name
elif device_info.suggested_area:
suggested_area = None
if device_info.suggested_area:
suggested_area = device_info.suggested_area
# Create/update main device
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
configuration_url=configuration_url,
@@ -824,36 +794,6 @@ def _async_setup_device_registry(
sw_version=sw_version,
suggested_area=suggested_area,
)
# Handle sub devices
# Find available areas from device_info
areas_by_id = {area.area_id: area for area in device_info.areas}
# Add the main device's area if it exists
if device_info.area:
areas_by_id[device_info.area.area_id] = device_info.area
# Create/update sub devices that should exist
for sub_device in device_info.devices:
# Determine the area for this sub device
sub_device_suggested_area: str | None = None
if sub_device.area_id is not None and sub_device.area_id in areas_by_id:
sub_device_suggested_area = areas_by_id[sub_device.area_id].name
sub_device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")},
name=sub_device.name or device_entry.name,
manufacturer=manufacturer,
model=model,
sw_version=sw_version,
suggested_area=sub_device_suggested_area,
)
# Update the sub device to set via_device_id
device_registry.async_update_device(
sub_device_entry.id,
via_device_id=device_entry.id,
)
return device_entry.id
+23 -103
View File
@@ -2,17 +2,9 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from pyezvizapi.constants import (
BatteryCameraWorkMode,
DeviceCatagories,
DeviceSwitchType,
SoundMode,
SupportExt,
)
from pyezvizapi.constants import DeviceSwitchType, SoundMode
from pyezvizapi.exceptions import HTTPError, PyEzvizError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -32,83 +24,17 @@ class EzvizSelectEntityDescription(SelectEntityDescription):
"""Describe a EZVIZ Select entity."""
supported_switch: int
current_option: Callable[[EzvizSelect], str | None]
select_option: Callable[[EzvizSelect, str, str], None]
def alarm_sound_mode_current_option(ezvizSelect: EzvizSelect) -> str | None:
"""Return the selected entity option to represent the entity state."""
sound_mode_value = getattr(
SoundMode, ezvizSelect.data[ezvizSelect.entity_description.key]
).value
if sound_mode_value in [0, 1, 2]:
return ezvizSelect.options[sound_mode_value]
return None
def alarm_sound_mode_select_option(
ezvizSelect: EzvizSelect, serial: str, option: str
) -> None:
"""Change the selected option."""
sound_mode_value = ezvizSelect.options.index(option)
ezvizSelect.coordinator.ezviz_client.alarm_sound(serial, sound_mode_value, 1)
ALARM_SOUND_MODE_SELECT_TYPE = EzvizSelectEntityDescription(
SELECT_TYPE = EzvizSelectEntityDescription(
key="alarm_sound_mod",
translation_key="alarm_sound_mode",
entity_category=EntityCategory.CONFIG,
options=["soft", "intensive", "silent"],
supported_switch=DeviceSwitchType.ALARM_TONE.value,
current_option=alarm_sound_mode_current_option,
select_option=alarm_sound_mode_select_option,
)
def battery_work_mode_current_option(ezvizSelect: EzvizSelect) -> str | None:
"""Return the selected entity option to represent the entity state."""
battery_work_mode = getattr(
BatteryCameraWorkMode,
ezvizSelect.data[ezvizSelect.entity_description.key],
BatteryCameraWorkMode.UNKNOWN,
)
if battery_work_mode == BatteryCameraWorkMode.UNKNOWN:
return None
return battery_work_mode.name.lower()
def battery_work_mode_select_option(
ezvizSelect: EzvizSelect, serial: str, option: str
) -> None:
"""Change the selected option."""
battery_work_mode = getattr(BatteryCameraWorkMode, option.upper())
ezvizSelect.coordinator.ezviz_client.set_battery_camera_work_mode(
serial, battery_work_mode.value
)
BATTERY_WORK_MODE_SELECT_TYPE = EzvizSelectEntityDescription(
key="battery_camera_work_mode",
translation_key="battery_camera_work_mode",
icon="mdi:battery-sync",
entity_category=EntityCategory.CONFIG,
options=[
"plugged_in",
"high_performance",
"power_save",
"super_power_save",
"custom",
],
supported_switch=-1,
current_option=battery_work_mode_current_option,
select_option=battery_work_mode_select_option,
)
SELECT_TYPES = [ALARM_SOUND_MODE_SELECT_TYPE, BATTERY_WORK_MODE_SELECT_TYPE]
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
@@ -117,26 +43,12 @@ async def async_setup_entry(
"""Set up EZVIZ select entities based on a config entry."""
coordinator = entry.runtime_data
entities = [
EzvizSelect(coordinator, camera, ALARM_SOUND_MODE_SELECT_TYPE)
async_add_entities(
EzvizSelect(coordinator, camera)
for camera in coordinator.data
for switch in coordinator.data[camera]["switches"]
if switch == ALARM_SOUND_MODE_SELECT_TYPE.supported_switch
]
for camera in coordinator.data:
device_category = coordinator.data[camera].get("device_category")
supportExt = coordinator.data[camera].get("supportExt")
if (
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
and supportExt
and str(SupportExt.SupportBatteryManage.value) in supportExt
):
entities.append(
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
)
async_add_entities(entities)
if switch == SELECT_TYPE.supported_switch
)
class EzvizSelect(EzvizEntity, SelectEntity):
@@ -146,23 +58,31 @@ class EzvizSelect(EzvizEntity, SelectEntity):
self,
coordinator: EzvizDataUpdateCoordinator,
serial: str,
description: EzvizSelectEntityDescription,
) -> None:
"""Initialize the select entity."""
"""Initialize the sensor."""
super().__init__(coordinator, serial)
self._attr_unique_id = f"{serial}_{description.key}"
self.entity_description = description
self._attr_unique_id = f"{serial}_{SELECT_TYPE.key}"
self.entity_description = SELECT_TYPE
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
desc = cast(EzvizSelectEntityDescription, self.entity_description)
return desc.current_option(self)
sound_mode_value = getattr(
SoundMode, self.data[self.entity_description.key]
).value
if sound_mode_value in [0, 1, 2]:
return self.options[sound_mode_value]
return None
def select_option(self, option: str) -> None:
"""Change the selected option."""
desc = cast(EzvizSelectEntityDescription, self.entity_description)
sound_mode_value = self.options.index(option)
try:
return desc.select_option(self, self._serial, option)
self.coordinator.ezviz_client.alarm_sound(self._serial, sound_mode_value, 1)
except (HTTPError, PyEzvizError) as err:
raise HomeAssistantError(f"Cannot select option for {desc.key}") from err
raise HomeAssistantError(
f"Cannot set Warning sound level for {self.entity_id}"
) from err
@@ -68,16 +68,6 @@
"intensive": "Intensive",
"silent": "Silent"
}
},
"battery_camera_work_mode": {
"name": "Battery work mode",
"state": {
"plugged_in": "Plugged in",
"high_performance": "High performance",
"power_save": "Power save",
"super_power_save": "Super power saving",
"custom": "Custom"
}
}
},
"image": {
@@ -15,9 +15,8 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FritzConfigEntry
from .coordinator import ConnectionInfo, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
from .models import ConnectionInfo
_LOGGER = logging.getLogger(__name__)
+8 -3
View File
@@ -19,10 +19,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
FritzConfigEntry,
FritzData,
FritzDevice,
_is_tracked,
)
from .entity import FritzDeviceBase
from .helpers import _is_tracked
from .models import FritzDevice
_LOGGER = logging.getLogger(__name__)
+207 -16
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, ValuesView
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from functools import partial
@@ -34,6 +34,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -47,15 +48,6 @@ from .const import (
FRITZ_EXCEPTIONS,
MeshRoles,
)
from .helpers import _ha_is_stopping
from .models import (
ConnectionInfo,
Device,
FritzDevice,
HostAttributes,
HostInfo,
Interface,
)
_LOGGER = logging.getLogger(__name__)
@@ -64,13 +56,33 @@ FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN)
type FritzConfigEntry = ConfigEntry[AvmWrapper]
@dataclass
class FritzData:
"""Storage class for platform global data."""
def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
"""Check if device is already tracked."""
return any(mac in tracked for tracked in current_devices)
tracked: dict[str, set[str]] = field(default_factory=dict)
profile_switches: dict[str, set[str]] = field(default_factory=dict)
wol_buttons: dict[str, set[str]] = field(default_factory=dict)
def device_filter_out_from_trackers(
mac: str,
device: FritzDevice,
current_devices: ValuesView[set[str]],
) -> bool:
"""Check if device should be filtered out from trackers."""
reason: str | None = None
if device.ip_address == "":
reason = "Missing IP"
elif _is_tracked(mac, current_devices):
reason = "Already tracked"
if reason:
_LOGGER.debug(
"Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
)
return bool(reason)
def _ha_is_stopping(activity: str) -> None:
"""Inform that HA is stopping."""
_LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)
class ClassSetupMissing(Exception):
@@ -81,6 +93,68 @@ class ClassSetupMissing(Exception):
super().__init__("Function called before Class setup")
@dataclass
class Device:
"""FRITZ!Box device class."""
connected: bool
connected_to: str
connection_type: str
ip_address: str
name: str
ssid: str | None
wan_access: bool | None = None
class Interface(TypedDict):
"""Interface details."""
device: str
mac: str
op_mode: str
ssid: str | None
type: str
HostAttributes = TypedDict(
"HostAttributes",
{
"Index": int,
"IPAddress": str,
"MACAddress": str,
"Active": bool,
"HostName": str,
"InterfaceType": str,
"X_AVM-DE_Port": int,
"X_AVM-DE_Speed": int,
"X_AVM-DE_UpdateAvailable": bool,
"X_AVM-DE_UpdateSuccessful": str,
"X_AVM-DE_InfoURL": str | None,
"X_AVM-DE_MACAddressList": str | None,
"X_AVM-DE_Model": str | None,
"X_AVM-DE_URL": str | None,
"X_AVM-DE_Guest": bool,
"X_AVM-DE_RequestClient": str,
"X_AVM-DE_VPN": bool,
"X_AVM-DE_WANAccess": str,
"X_AVM-DE_Disallow": bool,
"X_AVM-DE_IsMeshable": str,
"X_AVM-DE_Priority": str,
"X_AVM-DE_FriendlyName": str,
"X_AVM-DE_FriendlyNameIsWriteable": str,
},
)
class HostInfo(TypedDict):
"""FRITZ!Box host info class."""
mac: str
name: str
ip: str
status: bool
class UpdateCoordinatorDataType(TypedDict):
"""Update coordinator data type."""
@@ -824,3 +898,120 @@ class AvmWrapper(FritzBoxTools):
"X_AVM-DE_WakeOnLANByMACAddress",
NewMACAddress=mac_address,
)
@dataclass
class FritzData:
"""Storage class for platform global data."""
tracked: dict[str, set[str]] = field(default_factory=dict)
profile_switches: dict[str, set[str]] = field(default_factory=dict)
wol_buttons: dict[str, set[str]] = field(default_factory=dict)
class FritzDevice:
"""Representation of a device connected to the FRITZ!Box."""
def __init__(self, mac: str, name: str) -> None:
"""Initialize device info."""
self._connected = False
self._connected_to: str | None = None
self._connection_type: str | None = None
self._ip_address: str | None = None
self._last_activity: datetime | None = None
self._mac = mac
self._name = name
self._ssid: str | None = None
self._wan_access: bool | None = False
def update(self, dev_info: Device, consider_home: float) -> None:
"""Update device info."""
utc_point_in_time = dt_util.utcnow()
if self._last_activity:
consider_home_evaluated = (
utc_point_in_time - self._last_activity
).total_seconds() < consider_home
else:
consider_home_evaluated = dev_info.connected
if not self._name:
self._name = dev_info.name or self._mac.replace(":", "_")
self._connected = dev_info.connected or consider_home_evaluated
if dev_info.connected:
self._last_activity = utc_point_in_time
self._connected_to = dev_info.connected_to
self._connection_type = dev_info.connection_type
self._ip_address = dev_info.ip_address
self._ssid = dev_info.ssid
self._wan_access = dev_info.wan_access
@property
def connected_to(self) -> str | None:
"""Return connected status."""
return self._connected_to
@property
def connection_type(self) -> str | None:
"""Return connected status."""
return self._connection_type
@property
def is_connected(self) -> bool:
"""Return connected status."""
return self._connected
@property
def mac_address(self) -> str:
"""Get MAC address."""
return self._mac
@property
def hostname(self) -> str:
"""Get Name."""
return self._name
@property
def ip_address(self) -> str | None:
"""Get IP address."""
return self._ip_address
@property
def last_activity(self) -> datetime | None:
"""Return device last activity."""
return self._last_activity
@property
def ssid(self) -> str | None:
"""Return device connected SSID."""
return self._ssid
@property
def wan_access(self) -> bool | None:
"""Return device wan access."""
return self._wan_access
class SwitchInfo(TypedDict):
"""FRITZ!Box switch info class."""
description: str
friendly_name: str
icon: str
type: str
callback_update: Callable
callback_switch: Callable
init_state: bool
@dataclass
class ConnectionInfo:
"""Fritz sensor connection information class."""
connection: str
mesh_role: MeshRoles
wan_enabled: bool
ipv6_active: bool
@@ -10,10 +10,15 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
FritzConfigEntry,
FritzData,
FritzDevice,
device_filter_out_from_trackers,
)
from .entity import FritzDeviceBase
from .helpers import device_filter_out_from_trackers
from .models import FritzDevice
_LOGGER = logging.getLogger(__name__)
+1 -2
View File
@@ -14,8 +14,7 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_DEVICE_NAME, DOMAIN
from .coordinator import AvmWrapper
from .models import FritzDevice
from .coordinator import AvmWrapper, FritzDevice
class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
-39
View File
@@ -1,39 +0,0 @@
"""Helpers for AVM FRITZ!Box."""
from __future__ import annotations
from collections.abc import ValuesView
import logging
from .models import FritzDevice
_LOGGER = logging.getLogger(__name__)
def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
"""Check if device is already tracked."""
return any(mac in tracked for tracked in current_devices)
def device_filter_out_from_trackers(
mac: str,
device: FritzDevice,
current_devices: ValuesView[set[str]],
) -> bool:
"""Check if device should be filtered out from trackers."""
reason: str | None = None
if device.ip_address == "":
reason = "Missing IP"
elif _is_tracked(mac, current_devices):
reason = "Already tracked"
if reason:
_LOGGER.debug(
"Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
)
return bool(reason)
def _ha_is_stopping(activity: str) -> None:
"""Inform that HA is stopping."""
_LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)
-182
View File
@@ -1,182 +0,0 @@
"""Models for AVM FRITZ!Box."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TypedDict
from homeassistant.util import dt as dt_util
from .const import MeshRoles
@dataclass
class Device:
"""FRITZ!Box device class."""
connected: bool
connected_to: str
connection_type: str
ip_address: str
name: str
ssid: str | None
wan_access: bool | None = None
class Interface(TypedDict):
"""Interface details."""
device: str
mac: str
op_mode: str
ssid: str | None
type: str
HostAttributes = TypedDict(
"HostAttributes",
{
"Index": int,
"IPAddress": str,
"MACAddress": str,
"Active": bool,
"HostName": str,
"InterfaceType": str,
"X_AVM-DE_Port": int,
"X_AVM-DE_Speed": int,
"X_AVM-DE_UpdateAvailable": bool,
"X_AVM-DE_UpdateSuccessful": str,
"X_AVM-DE_InfoURL": str | None,
"X_AVM-DE_MACAddressList": str | None,
"X_AVM-DE_Model": str | None,
"X_AVM-DE_URL": str | None,
"X_AVM-DE_Guest": bool,
"X_AVM-DE_RequestClient": str,
"X_AVM-DE_VPN": bool,
"X_AVM-DE_WANAccess": str,
"X_AVM-DE_Disallow": bool,
"X_AVM-DE_IsMeshable": str,
"X_AVM-DE_Priority": str,
"X_AVM-DE_FriendlyName": str,
"X_AVM-DE_FriendlyNameIsWriteable": str,
},
)
class HostInfo(TypedDict):
"""FRITZ!Box host info class."""
mac: str
name: str
ip: str
status: bool
class FritzDevice:
"""Representation of a device connected to the FRITZ!Box."""
def __init__(self, mac: str, name: str) -> None:
"""Initialize device info."""
self._connected = False
self._connected_to: str | None = None
self._connection_type: str | None = None
self._ip_address: str | None = None
self._last_activity: datetime | None = None
self._mac = mac
self._name = name
self._ssid: str | None = None
self._wan_access: bool | None = False
def update(self, dev_info: Device, consider_home: float) -> None:
"""Update device info."""
utc_point_in_time = dt_util.utcnow()
if self._last_activity:
consider_home_evaluated = (
utc_point_in_time - self._last_activity
).total_seconds() < consider_home
else:
consider_home_evaluated = dev_info.connected
if not self._name:
self._name = dev_info.name or self._mac.replace(":", "_")
self._connected = dev_info.connected or consider_home_evaluated
if dev_info.connected:
self._last_activity = utc_point_in_time
self._connected_to = dev_info.connected_to
self._connection_type = dev_info.connection_type
self._ip_address = dev_info.ip_address
self._ssid = dev_info.ssid
self._wan_access = dev_info.wan_access
@property
def connected_to(self) -> str | None:
"""Return connected status."""
return self._connected_to
@property
def connection_type(self) -> str | None:
"""Return connected status."""
return self._connection_type
@property
def is_connected(self) -> bool:
"""Return connected status."""
return self._connected
@property
def mac_address(self) -> str:
"""Get MAC address."""
return self._mac
@property
def hostname(self) -> str:
"""Get Name."""
return self._name
@property
def ip_address(self) -> str | None:
"""Get IP address."""
return self._ip_address
@property
def last_activity(self) -> datetime | None:
"""Return device last activity."""
return self._last_activity
@property
def ssid(self) -> str | None:
"""Return device connected SSID."""
return self._ssid
@property
def wan_access(self) -> bool | None:
"""Return device wan access."""
return self._wan_access
class SwitchInfo(TypedDict):
"""FRITZ!Box switch info class."""
description: str
friendly_name: str
icon: str
type: str
callback_update: Callable
callback_switch: Callable
init_state: bool
@dataclass
class ConnectionInfo:
"""Fritz sensor connection information class."""
connection: str
mesh_role: MeshRoles
wan_enabled: bool
ipv6_active: bool
+1 -2
View File
@@ -27,9 +27,8 @@ from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from .const import DSL_CONNECTION, UPTIME_DEVIATION
from .coordinator import FritzConfigEntry
from .coordinator import ConnectionInfo, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
from .models import ConnectionInfo
_LOGGER = logging.getLogger(__name__)
+9 -3
View File
@@ -25,10 +25,16 @@ from .const import (
WIFI_STANDARD,
MeshRoles,
)
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
FritzConfigEntry,
FritzData,
FritzDevice,
SwitchInfo,
device_filter_out_from_trackers,
)
from .entity import FritzBoxBaseEntity, FritzDeviceBase
from .helpers import device_filter_out_from_trackers
from .models import FritzDevice, SwitchInfo
_LOGGER = logging.getLogger(__name__)
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250625.0"]
"requirements": ["home-assistant-frontend==20250531.4"]
}
@@ -35,6 +35,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
@@ -189,7 +190,7 @@ async def async_setup_entry(
client = await hass.async_add_executor_job(_init_client)
await client.aio.models.get(
model=RECOMMENDED_CHAT_MODEL,
model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
)
except (APIError, Timeout) as err:
@@ -337,7 +337,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
@@ -389,13 +389,47 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = self.create_generate_content_config()
generateContentConfig.tools = tools or None
generateContentConfig.system_instruction = (
prompt if supports_system_instruction else None
)
generateContentConfig.automatic_function_calling = (
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
)
if not supports_system_instruction:
@@ -438,40 +472,3 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if not chat_log.unresponded_tool_results:
break
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
)
+8 -1
View File
@@ -92,6 +92,7 @@ from .const import (
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
COORDINATOR,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_CORE_INFO,
@@ -106,6 +107,7 @@ from .const import (
HASSIO_UPDATE_INTERVAL,
)
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioDataUpdateCoordinator,
get_addons_info,
get_addons_stats, # noqa: F401
@@ -555,9 +557,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
dev_reg = dr.async_get(hass)
coordinator = HassioDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = coordinator
hass.data[COORDINATOR] = coordinator
addon_coordinator = HassioAddOnDataUpdateCoordinator(hass, entry, dev_reg)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
def deprecated_setup_issue() -> None:
os_info = get_os_info(hass)
@@ -41,15 +41,15 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Binary sensor set up for Hass.io config entry."""
coordinator = hass.data[ADDONS_COORDINATOR]
addons_coordinator = hass.data[ADDONS_COORDINATOR]
async_add_entities(
HassioAddonBinarySensor(
addon=addon,
coordinator=coordinator,
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
)
+3
View File
@@ -71,6 +71,7 @@ EVENT_ISSUE_REMOVED = "issue_removed"
UPDATE_KEY_SUPERVISOR = "supervisor"
COORDINATOR = "hassio_coordinator"
ADDONS_COORDINATOR = "hassio_addons_coordinator"
@@ -85,9 +86,11 @@ DATA_OS_INFO = "hassio_os_info"
DATA_NETWORK_INFO = "hassio_network_info"
DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS = "hassio_addons"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version"
+166 -53
View File
@@ -30,6 +30,7 @@ from .const import (
CONTAINER_INFO,
CONTAINER_STATS,
CORE_CONTAINER,
DATA_ADDONS,
DATA_ADDONS_INFO,
DATA_ADDONS_STATS,
DATA_COMPONENT,
@@ -49,6 +50,7 @@ from .const import (
DATA_SUPERVISOR_INFO,
DATA_SUPERVISOR_STATS,
DOMAIN,
HASSIO_ADDON_UPDATE_INTERVAL,
HASSIO_UPDATE_INTERVAL,
REQUEST_REFRESH_DELAY,
SUPERVISOR_CONTAINER,
@@ -112,6 +114,16 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
return hass.data.get(DATA_NETWORK_INFO)
@callback
@bind_hass
def get_addons(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return Addons info.
Async friendly.
"""
return hass.data.get(DATA_ADDONS)
@callback
@bind_hass
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
@@ -279,8 +291,8 @@ def async_remove_addons_from_dev_reg(
dev_reg.async_remove_device(dev.id)
class HassioDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io status."""
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io Add-on status."""
config_entry: ConfigEntry
@@ -293,7 +305,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=HASSIO_UPDATE_INTERVAL,
update_interval=HASSIO_ADDON_UPDATE_INTERVAL,
# We don't want an immediate refresh since we want to avoid
# fetching the container stats right away and avoid hammering
# the Supervisor API on startup
@@ -305,7 +317,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
self.data = {}
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None
self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict(
lambda: defaultdict(set)
)
@@ -321,7 +332,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
new_data: dict[str, Any] = {}
supervisor_info = get_supervisor_info(self.hass) or {}
addons = get_addons(self.hass) or {}
addons_info = get_addons_info(self.hass) or {}
addons_stats = get_addons_stats(self.hass)
store_data = get_store(self.hass)
@@ -345,37 +356,14 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "")
),
}
for addon in supervisor_info.get("addons", [])
for addon in addons.get("addons", [])
}
if self.is_hass_os:
new_data[DATA_KEY_OS] = get_os_info(self.hass)
new_data[DATA_KEY_CORE] = {
**(get_core_info(self.hass) or {}),
**get_core_stats(self.hass),
}
new_data[DATA_KEY_SUPERVISOR] = {
**supervisor_info,
**get_supervisor_stats(self.hass),
}
new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {}
# If this is the initial refresh, register all addons and return the dict
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
)
async_register_core_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
)
async_register_supervisor_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR]
)
async_register_host_in_dev_reg(self.entry_id, self.dev_reg)
if self.is_hass_os:
async_register_os_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_OS]
)
# Remove add-ons that are no longer installed from device registry
supervisor_addon_devices = {
@@ -388,12 +376,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
async_remove_addons_from_dev_reg(self.dev_reg, stale_addons)
if not self.is_hass_os and (
dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")})
):
# Remove the OS device if it exists and the installation is not hassos
self.dev_reg.async_remove_device(dev.id)
# If there are new add-ons, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
@@ -419,23 +401,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
container_updates = self._container_updates
data = self.hass.data
hassio = self.hassio
updates = {
DATA_INFO: hassio.get_info(),
DATA_CORE_INFO: hassio.get_core_info(),
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
DATA_OS_INFO: hassio.get_os_info(),
}
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
updates[DATA_CORE_STATS] = hassio.get_core_stats()
if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats()
data[DATA_ADDONS] = await self.hassio.get_addons()
results = await asyncio.gather(*updates.values())
for key, result in zip(updates, results, strict=False):
data[key] = result
_addon_data = data[DATA_SUPERVISOR_INFO].get("addons", [])
_addon_data = data[DATA_ADDONS].get("addons", [])
all_addons: list[str] = []
started_addons: list[str] = []
for addon in _addon_data:
@@ -531,14 +499,159 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
) -> None:
"""Refresh data."""
if not scheduled and not raise_on_auth_failed:
# Force refreshing updates for non-scheduled updates
# Force reloading add-on updates for non-scheduled
# updates.
#
# If `raise_on_auth_failed` is set, it means this is
# the first refresh and we do not want to delay
# startup or cause a timeout so we only refresh the
# updates if this is not a scheduled refresh and
# we are not doing the first refresh.
try:
await self.supervisor_client.refresh_updates()
await self.supervisor_client.store.reload()
except SupervisorError as err:
_LOGGER.warning("Error on Supervisor API: %s", err)
await super()._async_refresh(
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
)
class HassioDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io status."""
config_entry: ConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=HASSIO_UPDATE_INTERVAL,
# We don't want an immediate refresh since we want to avoid
# fetching the container stats right away and avoid hammering
# the Supervisor API on startup
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
self.hassio = hass.data[DATA_COMPONENT]
self.data = {}
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None
self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict(
lambda: defaultdict(set)
)
self.supervisor_client = get_supervisor_client(hass)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
is_first_update = not self.data
try:
await self.force_data_refresh(is_first_update)
except HassioAPIError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
new_data: dict[str, Any] = {}
supervisor_info = get_supervisor_info(self.hass) or {}
if self.is_hass_os:
new_data[DATA_KEY_OS] = get_os_info(self.hass)
new_data[DATA_KEY_CORE] = {
**(get_core_info(self.hass) or {}),
**get_core_stats(self.hass),
}
new_data[DATA_KEY_SUPERVISOR] = {
**supervisor_info,
**get_supervisor_stats(self.hass),
}
new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {}
# If this is the initial refresh, register all main components
if is_first_update:
async_register_core_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
)
async_register_supervisor_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR]
)
async_register_host_in_dev_reg(self.entry_id, self.dev_reg)
if self.is_hass_os:
async_register_os_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_OS]
)
if not self.is_hass_os and (
dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")})
):
# Remove the OS device if it exists and the installation is not hassos
self.dev_reg.async_remove_device(dev.id)
return new_data
async def force_data_refresh(self, first_update: bool) -> None:
"""Force update of the addon info."""
container_updates = self._container_updates
data = self.hass.data
hassio = self.hassio
updates = {
DATA_INFO: hassio.get_info(),
DATA_CORE_INFO: hassio.get_core_info(),
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
DATA_OS_INFO: hassio.get_os_info(),
}
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
updates[DATA_CORE_STATS] = hassio.get_core_stats()
if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats()
results = await asyncio.gather(*updates.values())
for key, result in zip(updates, results, strict=False):
data[key] = result
@callback
def async_enable_container_updates(
self, slug: str, entity_id: str, types: set[str]
) -> CALLBACK_TYPE:
"""Enable updates for an add-on."""
enabled_updates = self._container_updates[slug]
for key in types:
enabled_updates[key].add(entity_id)
@callback
def _remove() -> None:
for key in types:
enabled_updates[key].remove(entity_id)
return _remove
async def _async_refresh(
self,
log_failures: bool = True,
raise_on_auth_failed: bool = False,
scheduled: bool = False,
raise_on_entry_error: bool = False,
) -> None:
"""Refresh data."""
if not scheduled and not raise_on_auth_failed:
# Force reloading updates of main components for
# non-scheduled updates.
#
# If `raise_on_auth_failed` is set, it means this is
# the first refresh and we do not want to delay
# startup or cause a timeout so we only refresh the
# updates if this is not a scheduled refresh and
# we are not doing the first refresh.
try:
await self.supervisor_client.reload_updates()
except SupervisorError as err:
_LOGGER.warning("Error on Supervisor API: %s", err)
@@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import ADDONS_COORDINATOR
from .coordinator import HassioDataUpdateCoordinator
from .const import ADDONS_COORDINATOR, COORDINATOR
from .coordinator import HassioAddOnDataUpdateCoordinator, HassioDataUpdateCoordinator
async def async_get_config_entry_diagnostics(
@@ -19,7 +19,8 @@ async def async_get_config_entry_diagnostics(
config_entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
coordinator: HassioDataUpdateCoordinator = hass.data[COORDINATOR]
addons_coordinator: HassioAddOnDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
@@ -50,5 +51,6 @@ async def async_get_config_entry_diagnostics(
return {
"coordinator_data": coordinator.data,
"addons_coordinator_data": addons_coordinator.data,
"devices": devices,
}
+3 -3
View File
@@ -21,17 +21,17 @@ from .const import (
KEY_TO_UPDATE_TYPES,
SUPERVISOR_CONTAINER,
)
from .coordinator import HassioDataUpdateCoordinator
from .coordinator import HassioAddOnDataUpdateCoordinator, HassioDataUpdateCoordinator
class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]):
"""Base entity for a Hass.io add-on."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioDataUpdateCoordinator,
coordinator: HassioAddOnDataUpdateCoordinator,
entity_description: EntityDescription,
addon: dict[str, Any],
) -> None:
@@ -226,6 +226,14 @@ class HassIO:
"""
return self.send_command("/ingress/panels", method="get")
@api_data
def get_addons(self) -> Coroutine:
"""Return data installed Add-ons.
This method returns a coroutine.
"""
return self.send_command("/addons", method="get")
@_api_bool
async def update_hass_api(
self, http_config: dict[str, Any], refresh_token: RefreshToken
+5 -3
View File
@@ -19,6 +19,7 @@ from .const import (
ATTR_MEMORY_PERCENT,
ATTR_VERSION,
ATTR_VERSION_LATEST,
COORDINATOR,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
@@ -114,20 +115,21 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Sensor set up for Hass.io config entry."""
coordinator = hass.data[ADDONS_COORDINATOR]
addons_coordinator = hass.data[ADDONS_COORDINATOR]
entities: list[
HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor | HostSensor
] = [
HassioAddonSensor(
addon=addon,
coordinator=coordinator,
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
]
coordinator = hass.data[COORDINATOR]
entities.extend(
CoreSensor(
coordinator=coordinator,
+13 -11
View File
@@ -24,6 +24,7 @@ from .const import (
ATTR_AUTO_UPDATE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
COORDINATOR,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_OS,
@@ -49,9 +50,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Supervisor update based on a config entry."""
coordinator = hass.data[ADDONS_COORDINATOR]
coordinator = hass.data[COORDINATOR]
entities = [
entities: list[UpdateEntity] = [
SupervisorSupervisorUpdateEntity(
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
@@ -62,15 +63,6 @@ async def async_setup_entry(
),
]
entities.extend(
SupervisorAddonUpdateEntity(
addon=addon,
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
)
if coordinator.is_hass_os:
entities.append(
SupervisorOSUpdateEntity(
@@ -79,6 +71,16 @@ async def async_setup_entry(
)
)
addons_coordinator = hass.data[ADDONS_COORDINATOR]
entities.extend(
SupervisorAddonUpdateEntity(
addon=addon,
coordinator=addons_coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
)
async_add_entities(entities)
@@ -4,8 +4,6 @@ from __future__ import annotations
from typing import Any
from aiohomeconnect.model import GetSetting, Status
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
@@ -13,30 +11,14 @@ from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
def _serialize_item(item: Status | GetSetting) -> dict[str, Any]:
"""Serialize a status or setting item to a dictionary."""
data = {"value": item.value}
if item.unit is not None:
data["unit"] = item.unit
if item.constraints is not None:
data["constraints"] = {
k: v for k, v in item.constraints.to_dict().items() if v is not None
}
return data
async def _generate_appliance_diagnostics(
appliance: HomeConnectApplianceData,
) -> dict[str, Any]:
return {
**appliance.info.to_dict(),
"status": {
key.value: _serialize_item(status)
for key, status in appliance.status.items()
},
"status": {key.value: status.value for key, status in appliance.status.items()},
"settings": {
key.value: _serialize_item(setting)
for key, setting in appliance.settings.items()
key.value: setting.value for key, setting in appliance.settings.items()
},
"programs": [program.raw_key for program in appliance.programs],
}
@@ -14,7 +14,7 @@
"macaddress": "68A40E*"
},
{
"hostname": "(bosch|neff|siemens)-*",
"hostname": "(siemens|neff)-*",
"macaddress": "38B4D3*"
}
],
@@ -7,8 +7,6 @@ import asyncio
import logging
from typing import Any
from ha_silabs_firmware_client import FirmwareUpdateClient
from homeassistant.components.hassio import (
AddonError,
AddonInfo,
@@ -24,17 +22,17 @@ from homeassistant.config_entries import (
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
async_flash_silabs_firmware,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_firmware_info,
guess_hardware_owners,
probe_silabs_firmware_info,
@@ -63,7 +61,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_install_task: asyncio.Task | None = None
self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task | None = None
def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders."""
@@ -80,6 +77,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return placeholders
async def _async_set_addon_config(
self, config: dict, addon_manager: AddonManager
) -> None:
"""Set add-on config."""
try:
await addon_manager.async_set_addon_options(config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
) from err
async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:
"""Return add-on info."""
try:
@@ -137,54 +150,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
)
)
async def _install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult:
assert self._device is not None
if not self.firmware_install_task:
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
manifest = await client.async_update_data()
fw_meta = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
fw_data = await client.async_fetch_firmware(fw_meta)
self.firmware_install_task = self.hass.async_create_task(
async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_type=None,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
),
f"Flash {firmware_name} firmware",
)
if not self.firmware_install_task.done():
return self.async_show_progress(
step_id=step_id,
progress_action="install_firmware",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
progress_task=self.firmware_install_task,
)
return self.async_show_progress_done(next_step_id=next_step_id)
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -195,133 +160,68 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
return await self.async_step_install_zigbee_firmware()
# Allow the stick to be used with ZHA without flashing
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
):
return await self.async_step_confirm_zigbee()
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
raise NotImplementedError
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": self._failed_addon_name,
},
)
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if user_input is None:
return self.async_show_form(
step_id="confirm_zigbee",
description_placeholders=self._get_translation_placeholders(),
)
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
)
return self._async_flow_finished()
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
"""Ensure the OTBR addon is set up and not running."""
# We install the OTBR addon no matter what, since it is required to use Thread
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
reason="not_hassio",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
# Only flash new firmware if we need to
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(fw_flasher_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon()
return await self.async_step_install_zigbee_flasher_addon()
if addon_info.state == AddonState.RUNNING:
# We only fail setup if we have an instance of OTBR running *and* it's
# pointing to different hardware
if addon_info.options["device"] != self._device:
return self.async_abort(
reason="otbr_addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
)
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_run_zigbee_flasher_addon()
# Otherwise, stop the addon before continuing to flash firmware
await otbr_manager.async_stop_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
)
return None
async def async_step_pick_firmware_thread(
async def async_step_install_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
"""Show progress dialog for installing the Zigbee flasher addon."""
return await self._install_addon(
get_zigbee_flasher_addon_manager(self.hass),
"install_zigbee_flasher_addon",
"run_zigbee_flasher_addon",
)
if result := await self._ensure_thread_addon_setup():
return result
return await self.async_step_install_thread_firmware()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
async def _install_addon(
self,
addon_manager: silabs_multiprotocol_addon.WaitingAddonManager,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult:
"""Install Thread firmware."""
raise NotImplementedError
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass)
"""Show progress dialog for installing an addon."""
addon_info = await self._async_get_addon_info(addon_manager)
_LOGGER.debug("OTBR addon info: %s", addon_info)
_LOGGER.debug("Flasher addon state: %s", addon_info)
if not self.addon_install_task:
self.addon_install_task = self.hass.async_create_task(
addon_manager.async_install_addon_waiting(),
"OTBR addon install",
"Addon install",
)
if not self.addon_install_task.done():
return self.async_show_progress(
step_id="install_otbr_addon",
step_id=step_id,
progress_action="install_addon",
description_placeholders={
**self._get_translation_placeholders(),
@@ -340,50 +240,208 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
finally:
self.addon_install_task = None
return self.async_show_progress_done(next_step_id="install_thread_firmware")
return self.async_show_progress_done(next_step_id=next_step_id)
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": self._failed_addon_name,
},
)
async def async_step_run_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure the flasher addon to point to the SkyConnect and run it."""
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(fw_flasher_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
}
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, fw_flasher_manager)
if not self.addon_start_task:
async def start_and_wait_until_done() -> None:
await fw_flasher_manager.async_start_addon_waiting()
# Now that the addon is running, wait for it to finish
await fw_flasher_manager.async_wait_until_addon_state(
AddonState.NOT_RUNNING
)
self.addon_start_task = self.hass.async_create_task(
start_and_wait_until_done()
)
if not self.addon_start_task.done():
return self.async_show_progress(
step_id="run_zigbee_flasher_addon",
progress_action="run_zigbee_flasher_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
progress_task=self.addon_start_task,
)
try:
await self.addon_start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
self._failed_addon_name = fw_flasher_manager.addon_name
self._failed_addon_reason = "addon_start_failed"
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_start_task = None
return self.async_show_progress_done(
next_step_id="uninstall_zigbee_flasher_addon"
)
async def async_step_uninstall_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Uninstall the flasher addon."""
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
if not self.addon_uninstall_task:
_LOGGER.debug("Uninstalling flasher addon")
self.addon_uninstall_task = self.hass.async_create_task(
fw_flasher_manager.async_uninstall_addon_waiting()
)
if not self.addon_uninstall_task.done():
return self.async_show_progress(
step_id="uninstall_zigbee_flasher_addon",
progress_action="uninstall_zigbee_flasher_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
progress_task=self.addon_uninstall_task,
)
try:
await self.addon_uninstall_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
# The uninstall failing isn't critical so we can just continue
finally:
self.addon_uninstall_task = None
return self.async_show_progress_done(next_step_id="confirm_zigbee")
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if user_input is not None:
await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
)
return self._async_flow_finished()
return self.async_show_form(
step_id="confirm_zigbee",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_pick_firmware_thread(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# We install the OTBR addon no matter what, since it is required to use Thread
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_start_otbr_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="otbr_addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
return await self._install_addon(
get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon"
)
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 460800,
"flow_control": True,
"autoflash_firmware": True,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, otbr_manager)
if not self.addon_start_task:
# Before we start the addon, confirm that the correct firmware is running
# and populate `self._probed_firmware_info` with the correct information
if not await self._probe_firmware_info(
probe_methods=(ApplicationType.SPINEL,)
):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
addon_info = await self._async_get_addon_info(otbr_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 460800,
"flow_control": True,
"autoflash_firmware": False,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
try:
await otbr_manager.async_set_addon_options(new_addon_config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
) from err
self.addon_start_task = self.hass.async_create_task(
otbr_manager.async_start_addon_waiting()
)
@@ -417,14 +475,20 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup."""
assert self._device is not None
if user_input is None:
return self.async_show_form(
step_id="confirm_otbr",
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# OTBR discovery is done automatically via hassio
return self._async_flow_finished()
if user_input is not None:
# OTBR discovery is done automatically via hassio
return self._async_flow_finished()
return self.async_show_form(
step_id="confirm_otbr",
description_placeholders=self._get_translation_placeholders(),
)
@abstractmethod
def _async_flow_finished(self) -> ConfigFlowResult:
@@ -10,6 +10,22 @@
"pick_firmware_thread": "Thread"
}
},
"install_zigbee_flasher_addon": {
"title": "Installing flasher",
"description": "Installing the Silicon Labs Flasher add-on."
},
"run_zigbee_flasher_addon": {
"title": "Installing Zigbee firmware",
"description": "Installing Zigbee firmware. This will take about a minute."
},
"uninstall_zigbee_flasher_addon": {
"title": "Removing flasher",
"description": "Removing the Silicon Labs Flasher add-on."
},
"zigbee_flasher_failed": {
"title": "Zigbee installation failed",
"description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again."
},
"confirm_zigbee": {
"title": "Zigbee setup complete",
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
@@ -39,7 +55,9 @@
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
},
"progress": {
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
"install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.",
"run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.",
"uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed."
}
}
},
@@ -92,6 +110,16 @@
"data": {
"disable_multi_pan": "Disable multiprotocol support"
}
},
"install_flasher_addon": {
"title": "The Silicon Labs Flasher add-on installation has started"
},
"configure_flasher_addon": {
"title": "The Silicon Labs Flasher add-on installation has started"
},
"start_flasher_addon": {
"title": "Installing firmware",
"description": "Zigbee firmware is now being installed. This will take a few minutes."
}
},
"error": {
@@ -2,12 +2,15 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
import logging
from typing import Any, cast
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
from yarl import URL
from homeassistant.components.update import (
@@ -17,12 +20,18 @@ from homeassistant.components.update import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.restore_state import ExtraStoredData
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
from .util import (
ApplicationType,
FirmwareInfo,
guess_firmware_info,
probe_silabs_firmware_info,
)
_LOGGER = logging.getLogger(__name__)
@@ -240,11 +249,19 @@ class BaseFirmwareUpdateEntity(
self._attr_update_percentage = round((offset * 100) / total_size)
self.async_write_ha_state()
# Switch to an indeterminate progress bar after installation is complete, since
# we probe the firmware after flashing
if offset == total_size:
self._attr_update_percentage = None
self.async_write_ha_state()
@asynccontextmanager
async def _temporarily_stop_hardware_owners(
self, device: str
) -> AsyncIterator[None]:
"""Temporarily stop addons and integrations communicating with the device."""
firmware_info = await guess_firmware_info(self.hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(self.hass))
yield
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
@@ -261,18 +278,49 @@ class BaseFirmwareUpdateEntity(
fw_data = await self.coordinator.client.async_fetch_firmware(
self._latest_firmware
)
fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data)
try:
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_type=self.bootloader_reset_type,
progress_callback=self._update_progress,
)
finally:
self._attr_in_progress = False
self.async_write_ha_state()
device = self._current_device
self._firmware_info_callback(firmware_info)
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=self.bootloader_reset_type,
)
async with self._temporarily_stop_hardware_owners(device):
try:
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=self._update_progress
)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
# Probe the running application type with indeterminate progress
self._attr_update_percentage = None
self.async_write_ha_state()
firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(self.entity_description.expected_firmware_type,),
)
if firmware_info is None:
raise HomeAssistantError(
"Failed to probe the firmware after flashing"
)
self._firmware_info_callback(firmware_info)
finally:
self._attr_in_progress = False
self.async_write_ha_state()
@@ -4,20 +4,18 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AsyncExitStack, asynccontextmanager
from collections.abc import AsyncIterator, Iterable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
import logging
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
@@ -335,52 +333,3 @@ async def probe_silabs_firmware_type(
return None
return fw_info.firmware_type
async def async_flash_silabs_firmware(
hass: HomeAssistant,
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=bootloader_reset_type,
)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info
@@ -32,7 +32,6 @@ from .const import (
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
NABU_CASA_FIRMWARE_RELEASES_URL,
PID,
PRODUCT,
SERIAL_NUMBER,
@@ -46,29 +45,19 @@ _LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
class FirmwareInstallFlowProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
class TranslationPlaceholderProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow`'s translation placeholders."""
def _get_translation_placeholders(self) -> dict[str, str]:
return {}
async def _install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult: ...
else:
# Multiple inheritance with `Protocol` seems to break
FirmwareInstallFlowProtocol = object
TranslationPlaceholderProtocol = object
class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant SkyConnect firmware methods."""
class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProtocol):
"""Translation placeholder mixin for Home Assistant SkyConnect."""
context: ConfigFlowContext
@@ -83,35 +72,9 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
return placeholders
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="skyconnect_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="skyconnect_openthread_rcp",
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
class HomeAssistantSkyConnectConfigFlow(
SkyConnectFirmwareMixin,
SkyConnectTranslationMixin,
firmware_config_flow.BaseFirmwareConfigFlow,
domain=DOMAIN,
):
@@ -244,7 +207,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
class HomeAssistantSkyConnectOptionsFlowHandler(
SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow
SkyConnectTranslationMixin, firmware_config_flow.BaseFirmwareOptionsFlow
):
"""Zigbee and Thread options flow handlers."""
@@ -48,6 +48,16 @@
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
}
},
"install_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
},
"configure_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
},
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
},
"pick_firmware": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
@@ -56,6 +66,18 @@
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
}
},
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@@ -98,7 +120,9 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
},
"config": {
@@ -112,6 +136,22 @@
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
}
},
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"uninstall_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@@ -151,7 +191,9 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
},
"exceptions": {
@@ -5,7 +5,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Protocol, final
from typing import Any, final
import aiohttp
import voluptuous as vol
@@ -31,7 +31,6 @@ from homeassistant.components.homeassistant_hardware.util import (
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
ConfigEntryBaseFlow,
ConfigFlowResult,
OptionsFlow,
)
@@ -42,7 +41,6 @@ from .const import (
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
NABU_CASA_FIRMWARE_RELEASES_URL,
RADIO_DEVICE,
ZHA_DOMAIN,
ZHA_HW_DISCOVERY_DATA,
@@ -59,59 +57,8 @@ STEP_HW_SETTINGS_SCHEMA = vol.Schema(
}
)
if TYPE_CHECKING:
class FirmwareInstallFlowProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
async def _install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult: ...
else:
# Multiple inheritance with `Protocol` seems to break
FirmwareInstallFlowProtocol = object
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="yellow_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="yellow_openthread_rcp",
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
class HomeAssistantYellowConfigFlow(
YellowFirmwareMixin, BaseFirmwareConfigFlow, domain=DOMAIN
):
class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Yellow."""
VERSION = 1
@@ -328,9 +275,7 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
class HomeAssistantYellowOptionsFlowHandler(
YellowFirmwareMixin,
BaseHomeAssistantYellowOptionsFlow,
BaseFirmwareOptionsFlow,
BaseHomeAssistantYellowOptionsFlow, BaseFirmwareOptionsFlow
):
"""Handle a firmware options flow for Home Assistant Yellow."""
@@ -71,6 +71,16 @@
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
}
},
"install_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
},
"configure_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
},
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
},
"pick_firmware": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
@@ -79,6 +89,18 @@
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
}
},
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@@ -123,7 +145,9 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
},
"entity": {
@@ -15,14 +15,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
PLATFORMS = [
Platform.LAWN_MOWER,
]
async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Husqvarna Autoconnect Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
channel_id = entry.data[CONF_CLIENT_ID]
@@ -56,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
return True
async def async_unload_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator: HusqvarnaCoordinator = entry.runtime_data
@@ -3,31 +3,30 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from automower_ble.mower import Mower
from bleak import BleakError
from bleak_retry_connector import close_stale_connections_by_address
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from . import HusqvarnaConfigEntry
SCAN_INTERVAL = timedelta(seconds=60)
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
"""Class to manage fetching data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: HusqvarnaConfigEntry,
config_entry: ConfigEntry,
mower: Mower,
address: str,
channel_id: str,
@@ -10,10 +10,10 @@ from homeassistant.components.lawn_mower import (
LawnMowerEntity,
LawnMowerEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HusqvarnaConfigEntry
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
from .entity import HusqvarnaAutomowerBleEntity
@@ -21,11 +21,11 @@ from .entity import HusqvarnaAutomowerBleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HusqvarnaConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AutomowerLawnMower integration from a config entry."""
coordinator = config_entry.runtime_data
coordinator: HusqvarnaCoordinator = config_entry.runtime_data
address = coordinator.address
async_add_entities(
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/ista_ecotrend",
"iot_class": "cloud_polling",
"loggers": ["pyecotrend_ista"],
"quality_scale": "gold",
"requirements": ["pyecotrend-ista==3.3.1"]
}
@@ -50,18 +50,14 @@ rules:
discovery:
status: exempt
comment: The integration is a web service, there are no discoverable devices.
docs-data-update: done
docs-examples:
status: done
comment: describes how to use the integration with the statistics dashboard
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: changes are very rare (usually takes years)
dynamic-devices: todo
entity-category:
status: done
comment: The default category is appropriate.
@@ -71,12 +67,8 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: integration has no repairs
stale-devices:
status: exempt
comment: integration has no stale devices
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
@@ -8,6 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"]
}
@@ -1,77 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no configuration parameters
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
Integration has no authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: done
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Device discovery has to be manually triggered in LCN. Manually adding devices is implemented.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default:
status: exempt
comment: |
Since all entities are configured manually, they are enabled by default.
entity-translations:
status: exempt
comment: |
Since all entities are configured manually, names are user-defined.
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: done
stale-devices:
status: exempt
comment: |
Device discovery has to be manually triggered in LCN. Manually removing devices is implemented.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
Integration is not making any HTTP requests.
strict-typing: todo
@@ -46,9 +46,6 @@
"motor_fault_short": "mdi:flash-off",
"motor_ot_amps": "mdi:flash-alert"
}
},
"total_cycles": {
"default": "mdi:counter"
}
},
"switch": {
@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.1"]
"requirements": ["pylitterbot==2024.2.0"]
}
@@ -115,14 +115,6 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
lambda robot: status.lower() if (status := robot.status_code) else None
),
),
RobotSensorEntityDescription[LitterRobot](
key="total_cycles",
translation_key="total_cycles",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda robot: robot.cycle_count,
),
],
LitterRobot4: [
RobotSensorEntityDescription[LitterRobot4](
@@ -118,10 +118,6 @@
"spf": "Pinch detect at startup"
}
},
"total_cycles": {
"name": "Total cycles",
"unit_of_measurement": "cycles"
},
"waste_drawer": {
"name": "Waste drawer"
}
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from pathlib import Path
from homeassistant.config_entries import ConfigEntry
@@ -10,18 +11,19 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util import slugify
from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, STORAGE_PATH
from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN, STORAGE_PATH
from .store import LocalCalendarStore
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CALENDAR]
type LocalCalendarConfigEntry = ConfigEntry[LocalCalendarStore]
async def async_setup_entry(
hass: HomeAssistant, entry: LocalCalendarConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Local Calendar from a config entry."""
hass.data.setdefault(DOMAIN, {})
if CONF_STORAGE_KEY not in entry.data:
hass.config_entries.async_update_entry(
entry,
@@ -38,23 +40,22 @@ async def async_setup_entry(
except OSError as err:
raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err
entry.runtime_data = store
hass.data[DOMAIN][entry.entry_id] = store
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: LocalCalendarConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_remove_entry(
hass: HomeAssistant, entry: LocalCalendarConfigEntry
) -> None:
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle removal of an entry."""
key = slugify(entry.data[CONF_CALENDAR_NAME])
path = Path(hass.config.path(STORAGE_PATH.format(key=key)))
@@ -23,13 +23,13 @@ from homeassistant.components.calendar import (
CalendarEntityFeature,
CalendarEvent,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import LocalCalendarConfigEntry
from .const import CONF_CALENDAR_NAME
from .const import CONF_CALENDAR_NAME, DOMAIN
from .store import LocalCalendarStore
_LOGGER = logging.getLogger(__name__)
@@ -39,11 +39,11 @@ PRODID = "-//homeassistant.io//local_calendar 1.0//EN"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LocalCalendarConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the local calendar platform."""
store = config_entry.runtime_data
store = hass.data[DOMAIN][config_entry.entry_id]
ics = await store.async_load()
calendar: Calendar = await hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, ics
@@ -5,14 +5,15 @@ from typing import Any
from ical.diagnostics import redact_ics
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import LocalCalendarConfigEntry
from .const import DOMAIN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: LocalCalendarConfigEntry
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
payload: dict[str, Any] = {
@@ -20,7 +21,7 @@ async def async_get_config_entry_diagnostics(
"timezone": str(dt_util.get_default_time_zone()),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo),
}
store = config_entry.runtime_data
store = hass.data[DOMAIN][config_entry.entry_id]
ics = await store.async_load()
payload["ics"] = "\n".join(redact_ics(ics))
return payload
+8 -6
View File
@@ -19,6 +19,7 @@ from aiolookin import (
)
from aiolookin.models import UDPCommandType, UDPEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -33,7 +34,7 @@ from .const import (
TYPE_TO_PLATFORM,
)
from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator
from .models import LookinConfigEntry, LookinData
from .models import LookinData
LOGGER = logging.getLogger(__name__)
@@ -90,7 +91,7 @@ class LookinUDPManager:
self._subscriptions = None
async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up lookin from a config entry."""
domain_data = hass.data.setdefault(DOMAIN, {})
host = entry.data[CONF_HOST]
@@ -171,7 +172,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo
)
)
entry.runtime_data = LookinData(
hass.data[DOMAIN][entry.entry_id] = LookinData(
host=host,
lookin_udp_subs=lookin_udp_subs,
lookin_device=lookin_device,
@@ -186,9 +187,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo
return True
async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.config_entries.async_loaded_entries(DOMAIN):
manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER]
@@ -197,7 +199,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> b
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove lookin config entry from a device."""
data: LookinData = hass.data[DOMAIN][entry.entry_id]
+5 -4
View File
@@ -20,6 +20,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_WHOLE,
@@ -29,10 +30,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TYPE_TO_PLATFORM
from .const import DOMAIN, TYPE_TO_PLATFORM
from .coordinator import LookinDataUpdateCoordinator
from .entity import LookinCoordinatorEntity
from .models import LookinConfigEntry, LookinData
from .models import LookinData
LOOKIN_FAN_MODE_IDX_TO_HASS: Final = [FAN_AUTO, FAN_LOW, FAN_MIDDLE, FAN_HIGH]
LOOKIN_SWING_MODE_IDX_TO_HASS: Final = [SWING_OFF, SWING_BOTH]
@@ -63,11 +64,11 @@ LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LookinConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the climate platform for lookin from a config entry."""
lookin_data = config_entry.runtime_data
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for remote in lookin_data.devices:
@@ -6,16 +6,13 @@ from collections.abc import Awaitable, Callable
from datetime import timedelta
import logging
import time
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS
if TYPE_CHECKING:
from .models import LookinConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -47,12 +44,12 @@ class LookinPushCoordinator:
class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""DataUpdateCoordinator to gather data for a specific lookin devices."""
config_entry: LookinConfigEntry
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LookinConfigEntry,
config_entry: ConfigEntry,
push_coordinator: LookinPushCoordinator,
name: str,
update_interval: timedelta | None = None,
+5 -4
View File
@@ -6,24 +6,25 @@ import logging
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TYPE_TO_PLATFORM
from .const import DOMAIN, TYPE_TO_PLATFORM
from .entity import LookinPowerPushRemoteEntity
from .models import LookinConfigEntry
from .models import LookinData
LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LookinConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the light platform for lookin from a config entry."""
lookin_data = config_entry.runtime_data
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for remote in lookin_data.devices:
@@ -12,14 +12,15 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TYPE_TO_PLATFORM
from .const import DOMAIN, TYPE_TO_PLATFORM
from .coordinator import LookinDataUpdateCoordinator
from .entity import LookinPowerPushRemoteEntity
from .models import LookinConfigEntry, LookinData
from .models import LookinData
LOGGER = logging.getLogger(__name__)
@@ -42,11 +43,11 @@ _FUNCTION_NAME_TO_FEATURE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LookinConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the media_player platform for lookin from a config entry."""
lookin_data = config_entry.runtime_data
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for remote in lookin_data.devices:
@@ -13,12 +13,8 @@ from aiolookin import (
Remote,
)
from homeassistant.config_entries import ConfigEntry
from .coordinator import LookinDataUpdateCoordinator
type LookinConfigEntry = ConfigEntry[LookinData]
@dataclass
class LookinData:
+5 -3
View File
@@ -10,12 +10,14 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import LookinDeviceCoordinatorEntity
from .models import LookinConfigEntry, LookinData
from .models import LookinData
LOGGER = logging.getLogger(__name__)
@@ -40,11 +42,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LookinConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lookin sensors from the config entry."""
lookin_data = config_entry.runtime_data
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
if lookin_data.lookin_device.model >= 2:
async_add_entities(
+17 -7
View File
@@ -2,22 +2,28 @@
from __future__ import annotations
import logging
import re
import aiohttp
from loqedAPI import loqed
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LoqedConfigEntry, LoqedDataCoordinator
from .const import DOMAIN
from .coordinator import LoqedDataCoordinator
PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR]
PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LoqedConfigEntry) -> bool:
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up loqed from a config entry."""
websession = async_get_clientsession(hass)
host = entry.data["bridge_ip"]
@@ -43,15 +49,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: LoqedConfigEntry) -> boo
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LoqedConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await entry.runtime_data.remove_webhooks()
coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id]
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.remove_webhooks()
return unload_ok
@@ -17,8 +17,6 @@ from .const import CONF_CLOUDHOOK_URL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type LoqedConfigEntry = ConfigEntry[LoqedDataCoordinator]
class BatteryMessage(TypedDict):
"""Properties in a battery update message."""
@@ -73,12 +71,12 @@ class StatusMessage(TypedDict):
class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]):
"""Data update coordinator for the loqed platform."""
config_entry: LoqedConfigEntry
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LoqedConfigEntry,
config_entry: ConfigEntry,
api: loqed.LoqedAPI,
lock: loqed.Lock,
) -> None:
@@ -168,9 +166,7 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]):
await self.lock.deleteWebhook(webhook_index)
async def async_cloudhook_generate_url(
hass: HomeAssistant, entry: LoqedConfigEntry
) -> str:
async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str:
"""Generate the full URL for a webhook_id."""
if CONF_CLOUDHOOK_URL not in entry.data:
webhook_url = await cloud.async_create_cloudhook(
+7 -3
View File
@@ -6,10 +6,12 @@ import logging
from typing import Any
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LoqedConfigEntry, LoqedDataCoordinator
from . import LoqedDataCoordinator
from .const import DOMAIN
from .entity import LoqedEntity
WEBHOOK_API_ENDPOINT = "/api/loqed/webhook"
@@ -19,11 +21,13 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: LoqedConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Loqed lock platform."""
async_add_entities([LoqedLock(entry.runtime_data)])
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([LoqedLock(coordinator)])
class LoqedLock(LoqedEntity, LockEntity):
+5 -3
View File
@@ -8,6 +8,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -16,7 +17,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LoqedConfigEntry, LoqedDataCoordinator, StatusMessage
from .const import DOMAIN
from .coordinator import LoqedDataCoordinator, StatusMessage
from .entity import LoqedEntity
SENSORS: Final[tuple[SensorEntityDescription, ...]] = (
@@ -41,11 +43,11 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LoqedConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Loqed lock platform."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(LoqedSensor(coordinator, sensor) for sensor in SENSORS)
+38 -8
View File
@@ -6,18 +6,25 @@ the integration name.
from __future__ import annotations
from luftdaten import Luftdaten
import logging
from typing import Any
from luftdaten import Luftdaten
from luftdaten.exceptions import LuftdatenError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_SENSOR_ID
from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator
from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LuftdatenConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sensor.Community as config entry."""
# For backwards compat, set unique ID
@@ -28,15 +35,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: LuftdatenConfigEntry) ->
sensor_community = Luftdaten(entry.data[CONF_SENSOR_ID])
coordinator = LuftdatenDataUpdateCoordinator(hass, entry, sensor_community)
async def async_update() -> dict[str, float | int]:
"""Update sensor/binary sensor data."""
try:
await sensor_community.get_data()
except LuftdatenError as err:
raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err
if not sensor_community.values:
raise UpdateFailed("Did not receive sensor data from Sensor.Community")
data: dict[str, float | int] = sensor_community.values
data.update(sensor_community.meta)
return data
coordinator: DataUpdateCoordinator[dict[str, Any]] = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN}_{sensor_community.sensor_id}",
update_interval=DEFAULT_SCAN_INTERVAL,
update_method=async_update,
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LuftdatenConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Sensor.Community config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
@@ -1,58 +0,0 @@
"""Support for Sensor.Community stations.
Sensor.Community was previously called Luftdaten, hence the domain differs from
the integration name.
"""
from __future__ import annotations
import logging
from luftdaten import Luftdaten
from luftdaten.exceptions import LuftdatenError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type LuftdatenConfigEntry = ConfigEntry[LuftdatenDataUpdateCoordinator]
class LuftdatenDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float | int]]):
"""Data update coordinator for Sensor.Community."""
config_entry: LuftdatenConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LuftdatenConfigEntry,
sensor_community: Luftdaten,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{sensor_community.sensor_id}",
update_interval=DEFAULT_SCAN_INTERVAL,
)
self._sensor_community = sensor_community
async def _async_update_data(self) -> dict[str, float | int]:
"""Update sensor/binary sensor data."""
try:
await self._sensor_community.get_data()
except LuftdatenError as err:
raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err
if not self._sensor_community.values:
raise UpdateFailed("Did not receive sensor data from Sensor.Community")
data: dict[str, float | int] = self._sensor_community.values
data.update(self._sensor_community.meta)
return data
@@ -5,11 +5,12 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_SENSOR_ID
from .coordinator import LuftdatenConfigEntry
from .const import CONF_SENSOR_ID, DOMAIN
TO_REDACT = {
CONF_LATITUDE,
@@ -19,8 +20,10 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LuftdatenConfigEntry
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
coordinator: DataUpdateCoordinator[dict[str, Any]] = hass.data[DOMAIN][
entry.entry_id
]
return async_redact_data(coordinator.data, TO_REDACT)
+8 -5
View File
@@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
@@ -22,10 +23,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN
from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -70,11 +73,11 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LuftdatenConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Sensor.Community sensor based on a config entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SensorCommunitySensor(
@@ -98,7 +101,7 @@ class SensorCommunitySensor(CoordinatorEntity, SensorEntity):
def __init__(
self,
*,
coordinator: LuftdatenDataUpdateCoordinator,
coordinator: DataUpdateCoordinator,
description: SensorEntityDescription,
sensor_id: int,
show_on_map: bool,
+4 -4
View File
@@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
DOMAIN = "lupusec"
NOTIFICATION_ID = "lupusec_notification"
NOTIFICATION_TITLE = "Lupusec Security Setup"
@@ -22,10 +24,8 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
type LupusecConfigEntry = ConfigEntry[lupupy.Lupusec]
async def async_setup_entry(hass: HomeAssistant, entry: LupusecConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
host = entry.data[CONF_HOST]
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LupusecConfigEntry) -> b
_LOGGER.error("Failed to connect to Lupusec device at %s", host)
return False
entry.runtime_data = lupusec_system
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -11,12 +11,12 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LupusecConfigEntry
from .const import DOMAIN
from . import DOMAIN
from .entity import LupusecDevice
SCAN_INTERVAL = timedelta(seconds=2)
@@ -24,11 +24,11 @@ SCAN_INTERVAL = timedelta(seconds=2)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LupusecConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an alarm control panel for a Lupusec device."""
data = config_entry.runtime_data
data = hass.data[DOMAIN][config_entry.entry_id]
alarm = await hass.async_add_executor_job(data.get_alarm)
@@ -12,10 +12,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LupusecConfigEntry
from . import DOMAIN
from .entity import LupusecBaseSensor
SCAN_INTERVAL = timedelta(seconds=2)
@@ -25,12 +26,12 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LupusecConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a binary sensors for a Lupusec device."""
data = config_entry.runtime_data
data = hass.data[DOMAIN][config_entry.entry_id]
device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR
+4 -3
View File
@@ -9,10 +9,11 @@ from typing import Any
import lupupy.constants as CONST
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LupusecConfigEntry
from . import DOMAIN
from .entity import LupusecBaseSensor
SCAN_INTERVAL = timedelta(seconds=2)
@@ -20,12 +21,12 @@ SCAN_INTERVAL = timedelta(seconds=2)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LupusecConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lupusec switch devices."""
data = config_entry.runtime_data
data = hass.data[DOMAIN][config_entry.entry_id]
device_types = CONST.TYPE_SWITCH
+10 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from aiolyric import Lyric
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
@@ -18,14 +19,14 @@ from .api import (
OAuth2SessionLyric,
)
from .const import DOMAIN
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
from .coordinator import LyricDataUpdateCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Honeywell Lyric from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -52,13 +53,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> boo
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+5 -3
View File
@@ -24,6 +24,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
@@ -37,6 +38,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import (
DOMAIN,
LYRIC_EXCEPTIONS,
PRESET_HOLD_UNTIL,
PRESET_NO_HOLD,
@@ -44,7 +46,7 @@ from .const import (
PRESET_TEMPORARY_HOLD,
PRESET_VACATION_HOLD,
)
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
from .coordinator import LyricDataUpdateCoordinator
from .entity import LyricDeviceEntity
_LOGGER = logging.getLogger(__name__)
@@ -119,11 +121,11 @@ SCHEMA_HOLD_TIME: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
entry: LyricConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell Lyric climate platform based on a config entry."""
coordinator = entry.runtime_data
coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
(
@@ -20,18 +20,16 @@ from .api import OAuth2SessionLyric
_LOGGER = logging.getLogger(__name__)
type LyricConfigEntry = ConfigEntry[LyricDataUpdateCoordinator]
class LyricDataUpdateCoordinator(DataUpdateCoordinator[Lyric]):
"""Data update coordinator for Honeywell Lyric."""
config_entry: LyricConfigEntry
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LyricConfigEntry,
config_entry: ConfigEntry,
oauth_session: OAuth2SessionLyric,
lyric: Lyric,
) -> None:
+5 -3
View File
@@ -16,6 +16,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,13 +24,14 @@ from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .const import (
DOMAIN,
PRESET_HOLD_UNTIL,
PRESET_NO_HOLD,
PRESET_PERMANENT_HOLD,
PRESET_TEMPORARY_HOLD,
PRESET_VACATION_HOLD,
)
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
from .coordinator import LyricDataUpdateCoordinator
from .entity import LyricAccessoryEntity, LyricDeviceEntity
LYRIC_SETPOINT_STATUS_NAMES = {
@@ -157,11 +159,11 @@ def get_datetime_from_future_time(time_str: str) -> datetime:
async def async_setup_entry(
hass: HomeAssistant,
entry: LyricConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell Lyric sensor platform based on a config entry."""
coordinator = entry.runtime_data
coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LyricSensor(
+6 -18
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, cast
@@ -74,11 +74,6 @@ OPERATIONAL_STATE_MAP = {
clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running",
clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused",
clusters.OperationalState.Enums.OperationalStateEnum.kError: "error",
}
RVC_OPERATIONAL_STATE_MAP = {
# enum with known Operation state values which we can translate
**OPERATIONAL_STATE_MAP,
clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger",
clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging",
clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked",
@@ -176,10 +171,6 @@ class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescriptio
state_list_attribute: type[ClusterAttributeDescriptor] = (
clusters.OperationalState.Attributes.OperationalStateList
)
state_attribute: type[ClusterAttributeDescriptor] = (
clusters.OperationalState.Attributes.OperationalState
)
state_map: dict[int, str] = field(default_factory=lambda: OPERATIONAL_STATE_MAP)
class MatterSensor(MatterEntity, SensorEntity):
@@ -254,15 +245,15 @@ class MatterOperationalStateSensor(MatterSensor):
for state in operational_state_list:
# prefer translateable (known) state from mapping,
# fallback to the raw state label as given by the device/manufacturer
states_map[state.operationalStateID] = (
self.entity_description.state_map.get(
state.operationalStateID, slugify(state.operationalStateLabel)
)
states_map[state.operationalStateID] = OPERATIONAL_STATE_MAP.get(
state.operationalStateID, slugify(state.operationalStateLabel)
)
self.states_map = states_map
self._attr_options = list(states_map.values())
self._attr_native_value = states_map.get(
self.get_matter_attribute_value(self.entity_description.state_attribute)
self.get_matter_attribute_value(
clusters.OperationalState.Attributes.OperationalState
)
)
@@ -1008,8 +999,6 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.ENUM,
translation_key="operational_state",
state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList,
state_attribute=clusters.RvcOperationalState.Attributes.OperationalState,
state_map=RVC_OPERATIONAL_STATE_MAP,
),
entity_class=MatterOperationalStateSensor,
required_attributes=(
@@ -1027,7 +1016,6 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.ENUM,
translation_key="operational_state",
state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList,
state_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalState,
),
entity_class=MatterOperationalStateSensor,
required_attributes=(
+10 -9
View File
@@ -30,10 +30,10 @@ class OperationalState(IntEnum):
Combination of generic OperationalState and RvcOperationalState.
"""
STOPPED = 0x00
RUNNING = 0x01
PAUSED = 0x02
ERROR = 0x03
NO_ERROR = 0x00
UNABLE_TO_START_OR_RESUME = 0x01
UNABLE_TO_COMPLETE_OPERATION = 0x02
COMMAND_INVALID_IN_STATE = 0x03
SEEKING_CHARGER = 0x40
CHARGING = 0x41
DOCKED = 0x42
@@ -95,7 +95,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
async def async_pause(self) -> None:
"""Pause the cleaning task."""
await self.send_device_command(clusters.RvcOperationalState.Commands.Pause())
await self.send_device_command(clusters.OperationalState.Commands.Pause())
@callback
def _update_from_device(self) -> None:
@@ -120,10 +120,11 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
state = VacuumActivity.DOCKED
elif operational_state == OperationalState.SEEKING_CHARGER:
state = VacuumActivity.RETURNING
elif operational_state == OperationalState.ERROR:
elif operational_state in (
OperationalState.UNABLE_TO_COMPLETE_OPERATION,
OperationalState.UNABLE_TO_START_OR_RESUME,
):
state = VacuumActivity.ERROR
elif operational_state == OperationalState.PAUSED:
state = VacuumActivity.PAUSED
elif (run_mode := self._supported_run_modes.get(run_mode_raw)) is not None:
tags = {x.value for x in run_mode.modeTags}
if ModeTag.CLEANING in tags:
@@ -200,7 +201,7 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterVacuum,
required_attributes=(
clusters.RvcRunMode.Attributes.CurrentMode,
clusters.RvcOperationalState.Attributes.OperationalState,
clusters.RvcOperationalState.Attributes.CurrentPhase,
),
optional_attributes=(
clusters.RvcCleanMode.Attributes.CurrentMode,

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